Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a6ae856ec | ||
|
|
9c6407ae74 | ||
|
|
b9b8cc2c77 | ||
|
|
e02fdbdc8f | ||
|
|
ce529c9337 | ||
|
|
6952b22f45 | ||
|
|
3aa06e32fa | ||
|
|
97fc783cc0 | ||
|
|
a18b1e5fe4 | ||
|
|
b17e030d1f | ||
|
|
03258ca045 | ||
|
|
afd8422580 | ||
|
|
4b3e0e5046 | ||
|
|
a6f69a06ce | ||
|
|
2016e7f6dc | ||
|
|
95986bbfc5 | ||
|
|
322077be89 | ||
|
|
a7a0a97115 | ||
|
|
cbaa8c9bf5 | ||
|
|
0f5ecb609b | ||
|
|
5094c63755 | ||
|
|
4a78e7c1d9 | ||
|
|
0c2a9cb2b3 | ||
|
|
c9d4783968 | ||
|
|
2116122c95 | ||
|
|
4c3450792a | ||
|
|
f55f9bc3a6 | ||
|
|
d0e6aba319 | ||
|
|
b0b18986b1 | ||
|
|
abecb0dee1 | ||
|
|
d17f50b9de | ||
|
|
2734e3e1ce | ||
|
|
10417396f1 | ||
|
|
41a3724e9e | ||
|
|
97b6022017 | ||
|
|
410a63ea9a | ||
|
|
58c382efb7 | ||
|
|
3598586bc0 | ||
|
|
504db23823 | ||
|
|
b066a14f0c | ||
|
|
8984a1c454 | ||
|
|
94d4b5dcc9 | ||
|
|
5cbf18713a | ||
|
|
78d94380e7 | ||
|
|
4b555515cd | ||
|
|
ec83d8ff53 | ||
|
|
671a41437d | ||
|
|
9f1b3e8254 | ||
|
|
c718b051c2 | ||
|
|
6a93f14e0a | ||
|
|
913b7cf9a9 | ||
|
|
c3a3b59ad2 | ||
|
|
5b0cd561ff | ||
|
|
6b3f2e29bd | ||
|
|
c2c260b3a8 | ||
|
|
8c00068364 | ||
|
|
a354144412 | ||
|
|
d525648b45 | ||
|
|
427d0cc1fc | ||
|
|
1f6010f998 | ||
|
|
d9ba74aac0 | ||
|
|
90070840f1 | ||
|
|
14818775c5 | ||
|
|
f3f1174407 | ||
|
|
ce6d5fb538 | ||
|
|
43882bf959 | ||
|
|
55c6a09bbc | ||
|
|
a55880ce82 | ||
|
|
286376df93 | ||
|
|
88cd453db6 | ||
|
|
6fdf6e0678 | ||
|
|
b8deec9087 | ||
|
|
dbee2de1d5 | ||
|
|
3e27af8e21 | ||
|
|
4d13905cb8 | ||
|
|
18c8e8390c | ||
|
|
ae6d9aa16d | ||
|
|
357b0955b1 | ||
|
|
a55bd9bff8 | ||
|
|
1dabfa1dc6 | ||
|
|
516bd5d99c | ||
|
|
9818f7779a | ||
|
|
65a00b497b | ||
|
|
0e856f5a6e | ||
|
|
e08fb484a8 | ||
|
|
2df3b99e26 | ||
|
|
21c6581975 | ||
|
|
3e4a3fb9d9 | ||
|
|
1f1c27d68f | ||
|
|
f2a3025f41 | ||
|
|
5282a55a54 | ||
|
|
1b8ca149cb | ||
|
|
59978d9c06 | ||
|
|
8c25736f39 | ||
|
|
dacf166935 | ||
|
|
54aefaaf60 | ||
|
|
0a90c0350d | ||
|
|
654b740532 | ||
|
|
6910bda9c7 | ||
|
|
f256d7a43c | ||
|
|
488411615f | ||
|
|
01c1428b66 | ||
|
|
f6a699227e | ||
|
|
185fbb1544 | ||
|
|
b6c508acdf | ||
|
|
1d4c51a4d3 | ||
|
|
9472f4a1d9 | ||
|
|
90e27c4b86 | ||
|
|
132dea9aaa | ||
|
|
5034c332fe |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
|
||||
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
|
||||
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
|
||||
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
|
||||
between a local folder and a Drive folder. Differences come from exact MD5 by
|
||||
default or from modification time with `--quick`; `status` is read-only, `pull`
|
||||
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
|
||||
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
|
||||
Only regular files are transferred — online documents and shortcuts are skipped,
|
||||
neither side deletes extra files, downloads are staged through a temporary file
|
||||
and committed with an atomic rename, and remote names that would escape
|
||||
`--local-folder` are reported as failures instead of being written. Every command
|
||||
prints a structured summary on stdout and exits non-zero when any item fails.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
|
||||
+173
-38
@@ -911,7 +911,7 @@ jobs:
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -926,10 +926,6 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -951,41 +947,33 @@ jobs:
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run current unit tests with coverage
|
||||
- name: Run scoped unit tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
echo "No buildable Go package needs scoped coverage." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
mapfile -t changed_packages <<< "$changed_output"
|
||||
mapfile -t impacted_packages <<< "$impacted_output"
|
||||
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
|
||||
go test -count=1 -p 1 \
|
||||
-coverpkg="$coverpkg" \
|
||||
-coverprofile=coverage.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
else
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
echo "No buildable Go package needs scoped coverage." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
mapfile -t changed_packages <<< "$changed_output"
|
||||
mapfile -t impacted_packages <<< "$impacted_output"
|
||||
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
|
||||
go test -count=1 -p 1 \
|
||||
-coverpkg="$coverpkg" \
|
||||
-coverprofile=coverage.txt \
|
||||
-covermode=atomic \
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
|
||||
go tool cover -func=coverage.txt
|
||||
@@ -998,6 +986,66 @@ jobs:
|
||||
path: coverage.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-current-full:
|
||||
name: "Coverage (current: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- cli
|
||||
- generators
|
||||
- helpers
|
||||
- remaining
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
# Each shard keeps -p 1 so the authoritative measurement stays serial
|
||||
# inside one instrumented process group; shards run on isolated runners,
|
||||
# and scripts/ci/test-packages.sh verify proves the shard union equals
|
||||
# the previous single full-suite package set exactly once.
|
||||
- name: Run current shard tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
COVERAGE_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD")"
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \
|
||||
-covermode=atomic \
|
||||
"${packages[@]}"
|
||||
go tool cover -func="coverage-shard-$COVERAGE_SHARD.txt" | tail -n 1
|
||||
|
||||
- name: Upload current shard coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-current-shard-${{ matrix.shard }}
|
||||
path: coverage-shard-${{ matrix.shard }}.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-supporting:
|
||||
name: Coverage (supporting)
|
||||
needs: lint
|
||||
@@ -1051,14 +1099,11 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -1076,7 +1121,34 @@ jobs:
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
# The merge-base full-suite profile is a pure function of the base
|
||||
# commit. Reuse the profile published by the last green push run of
|
||||
# exactly that commit instead of re-running the whole suite; any key
|
||||
# mismatch falls back to authoritative recomputation. Exact key only,
|
||||
# never prefix fallback: a near-miss profile would compare the
|
||||
# candidate against the wrong commit.
|
||||
- name: Restore cached merge-base coverage profile
|
||||
id: baseline-cache
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Materialize cached merge-base coverage profile
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
cp coverage-cache.txt coverage-base.txt
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
if: steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
@@ -1125,6 +1197,21 @@ jobs:
|
||||
fi
|
||||
)
|
||||
|
||||
- name: Prepare merge-base coverage profile cache
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-base.txt
|
||||
test "$(head -n 1 coverage-base.txt)" = "mode: atomic"
|
||||
cp coverage-base.txt coverage-cache.txt
|
||||
|
||||
- name: Save merge-base coverage profile cache
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Upload baseline coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -1137,6 +1224,7 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- coverage-current
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-darwin
|
||||
@@ -1152,6 +1240,7 @@ jobs:
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
||||
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
@@ -1159,6 +1248,7 @@ jobs:
|
||||
run: |
|
||||
failed=0
|
||||
current_expected=success
|
||||
current_full_expected=skipped
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
native_expected=skipped
|
||||
@@ -1166,6 +1256,8 @@ jobs:
|
||||
current_expected=skipped
|
||||
baseline_expected=skipped
|
||||
elif [ "$FULL_SUITE" = true ]; then
|
||||
current_expected=skipped
|
||||
current_full_expected=success
|
||||
supporting_expected=success
|
||||
fi
|
||||
if [ "$CHANGELOG_ONLY" != true ] &&
|
||||
@@ -1176,6 +1268,7 @@ jobs:
|
||||
|
||||
for profile in \
|
||||
"current:$CURRENT_RESULT:$current_expected" \
|
||||
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
|
||||
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected"
|
||||
do
|
||||
@@ -1211,6 +1304,7 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -1234,11 +1328,12 @@ jobs:
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profile
|
||||
- name: Download current coverage profiles
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
pattern: coverage-current-*
|
||||
merge-multiple: true
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
@@ -1255,6 +1350,26 @@ jobs:
|
||||
name: coverage-baseline-profile
|
||||
path: .
|
||||
|
||||
# Shard profiles cover disjoint package sets, so their block-level
|
||||
# concatenation is the same candidate profile one serial run produced.
|
||||
# Every expected shard must be present; a missing shard would silently
|
||||
# shrink the scope-matched overall comparison.
|
||||
- name: Assemble full-suite coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test ! -f coverage.txt
|
||||
for shard in app cli generators helpers remaining; do
|
||||
profile="coverage-shard-$shard.txt"
|
||||
test -f "$profile"
|
||||
test "$(head -n 1 "$profile")" = "mode: atomic"
|
||||
done
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
for shard in app cli generators helpers remaining; do
|
||||
tail -n +2 "coverage-shard-$shard.txt" >> coverage.txt
|
||||
done
|
||||
|
||||
- name: Enforce coverage gate
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
env:
|
||||
@@ -1275,6 +1390,26 @@ jobs:
|
||||
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
|
||||
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
# Publish this push's full-suite profile as the merge-base cache for
|
||||
# future PRs whose merge-base is exactly this commit. Saved only after
|
||||
# the gate passed so a broken run never becomes a baseline. Both producer
|
||||
# and consumer use coverage-cache.txt because the cache version includes
|
||||
# the configured path as well as the compression tool.
|
||||
- name: Prepare push coverage profile as merge-base cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage.txt
|
||||
test "$(head -n 1 coverage.txt)" = "mode: atomic"
|
||||
cp coverage.txt coverage-cache.txt
|
||||
|
||||
- name: Save push coverage profile as merge-base cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
run: |
|
||||
|
||||
@@ -6,6 +6,70 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59-beta.1] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
|
||||
file|folder`, `--start`, and `--end` for client-side filtering by node type
|
||||
and modification time on both the pan and workspace routes. Filtering runs
|
||||
a bounded full scan of the target directory (2000-entry cap, reported via
|
||||
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
|
||||
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
|
||||
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
|
||||
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
|
||||
|
||||
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
|
||||
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
|
||||
between a local folder and a Drive folder. Differences come from exact MD5 by
|
||||
default or from modification time with `--quick`; `status` is read-only, `pull`
|
||||
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
|
||||
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
|
||||
Only regular files are transferred — online documents and shortcuts are skipped,
|
||||
neither side deletes extra files, downloads are staged through a temporary file
|
||||
and committed with an atomic rename, and remote names that would escape
|
||||
`--local-folder` are reported as failures instead of being written. Every command
|
||||
prints a structured summary on stdout and exits non-zero when any item fails.
|
||||
|
||||
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
|
||||
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
|
||||
single-layer pan route now filters the returned page by name pattern; the
|
||||
flag was previously accepted but silently ignored.
|
||||
|
||||
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
|
||||
ranks the filtered entries (folders included when `--type folder` is set)
|
||||
instead of unconditionally dropping folders, so the documented combination
|
||||
returns the most recently modified folders rather than an empty list.
|
||||
|
||||
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
|
||||
|
||||
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
|
||||
|
||||
|
||||
## [1.0.58] - 2026-08-13
|
||||
|
||||
This release promotes the sealed `v1.0.58-beta.6` contents to stable.
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.58-beta.6"
|
||||
version "1.0.59-beta.1"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz"
|
||||
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz"
|
||||
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz"
|
||||
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip"
|
||||
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip"
|
||||
sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -18,7 +18,7 @@ help:
|
||||
@printf "Available targets:\n"
|
||||
@printf " make build - Build the dws CLI binary\n"
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
|
||||
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
|
||||
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
|
||||
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
|
||||
@@ -786,6 +786,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
|
||||
## Reference & Docs
|
||||
|
||||
- [International DingTalk (`.io`) guide](./docs/international-region-guide.md) — international login, domestic/international profile switching, isolated testing, and troubleshooting
|
||||
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
|
||||
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
|
||||
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
|
||||
|
||||
@@ -777,6 +777,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
|
||||
## 参考与文档
|
||||
|
||||
- [国际版(`.io`)使用手册](./docs/international-region-guide.zh-CN.md) — 国际版登录、国内/国际 profile 切换、隔离验证与排障
|
||||
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
|
||||
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
|
||||
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
|
||||
|
||||
+15
-2
@@ -201,8 +201,21 @@ base_ref=$(git merge-base HEAD origin/main)
|
||||
standard PR, CI derives changed packages and their reverse-dependency test
|
||||
closure, then generates candidate and merge-base profiles with the same test
|
||||
scope and `coverpkg`. High-risk and protected-main runs use the complete
|
||||
profiles. Supporting and (when platform-selected) native profiles are
|
||||
generated before the aggregate `Coverage` context evaluates them. The
|
||||
profiles. The complete candidate profile is produced by disjoint per-shard
|
||||
helper jobs (`scripts/ci/test-packages.sh list-coverage`, kept serial with
|
||||
`-p 1` inside each shard; `verify` proves the shard union equals the
|
||||
full-suite scope exactly once) and concatenated in the aggregate job before
|
||||
enforcement. The complete merge-base profile is restored from an exact-key
|
||||
cache written by the last green `main` push of that same commit (key:
|
||||
merge-base SHA plus resolved Go version); any miss falls back to recomputing
|
||||
it in a merge-base worktree. The trusted `main` producer and PR consumer use
|
||||
the same dedicated cache profile path because GitHub includes that path in the
|
||||
cache version; the runtime-facing candidate and baseline filenames remain
|
||||
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
|
||||
keys, because a neighbouring commit's profile would compare the candidate
|
||||
against the wrong baseline. Supporting and (when
|
||||
platform-selected) native profiles are generated before the aggregate
|
||||
`Coverage` context evaluates them. The
|
||||
aggregate and native gates require 100% coverage for changed executable Go
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance,
|
||||
scope-matched merge-base non-regression check. Candidate and baseline profiles
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# International DingTalk (`.io`) Guide
|
||||
|
||||
This guide explains how to log in to the international DingTalk region and run DWS commands against `*.dingtalk.io` services.
|
||||
|
||||
## Region behavior
|
||||
|
||||
- `dws auth login --intl` creates or refreshes an international login using the `.io` login, OAuth, and MCP services.
|
||||
- Omitting `--intl` keeps the existing domestic `.com` behavior.
|
||||
- `--intl` is a login option, not a global option for business commands. After login, commands such as `contact`, `calendar`, and `doc` derive the region from the selected Token/profile.
|
||||
- Each new Token records its login region. Switching profiles therefore switches the official DingTalk gateway region automatically.
|
||||
- `--international` is a compatibility alias. Prefer `--intl` in new scripts.
|
||||
|
||||
For the complete Chinese guide, see [DWS 国际版(DingTalk `.io`)使用手册](./international-region-guide.zh-CN.md).
|
||||
|
||||
## Check availability
|
||||
|
||||
```bash
|
||||
dws auth login --help
|
||||
```
|
||||
|
||||
The help output must include `--intl` and `--international`.
|
||||
|
||||
When validating a source checkout, build it first and use `./dws` so an older binary on `PATH` is not invoked accidentally:
|
||||
|
||||
```bash
|
||||
make build
|
||||
./dws auth login --help
|
||||
```
|
||||
|
||||
## Log in
|
||||
|
||||
Browser login:
|
||||
|
||||
```bash
|
||||
dws auth login --intl
|
||||
```
|
||||
|
||||
Device flow for SSH, containers, and headless environments:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --device
|
||||
```
|
||||
|
||||
User OAuth with custom application credentials:
|
||||
|
||||
```bash
|
||||
dws auth login --intl \
|
||||
--client-id <APP_KEY> \
|
||||
--client-secret <APP_SECRET>
|
||||
```
|
||||
|
||||
This mode still requires the user to complete OAuth authorization in a browser; it is not a userless `client_credentials` login. The application must be configured on the international developer platform with the required callback and permissions. Never commit an AppSecret to source control or include it in logs.
|
||||
|
||||
## Verify the login
|
||||
|
||||
```bash
|
||||
dws auth status --format json
|
||||
dws profile list --format json
|
||||
dws contact user get-self
|
||||
```
|
||||
|
||||
The last command is a read-only smoke check. If the organization has not enabled CLI access, an organization administrator must enable it or approve the access request on the international developer platform.
|
||||
|
||||
## Use domestic and international profiles together
|
||||
|
||||
```bash
|
||||
# Domestic (.com)
|
||||
dws auth login
|
||||
|
||||
# International (.io)
|
||||
dws auth login --intl
|
||||
|
||||
# Find the stable profile selectors
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
Persistently switch profiles:
|
||||
|
||||
```bash
|
||||
dws profile switch <corpId>:<userId>
|
||||
```
|
||||
|
||||
Toggle back to the previous profile:
|
||||
|
||||
```bash
|
||||
dws profile switch -
|
||||
```
|
||||
|
||||
Select a profile for one command without changing the default:
|
||||
|
||||
```bash
|
||||
dws --profile <corpId>:<userId> contact user get-self
|
||||
```
|
||||
|
||||
Do not add `--intl` to business commands. DWS routes official endpoints from the selected profile's Token region.
|
||||
|
||||
## Isolated smoke testing
|
||||
|
||||
Use a separate configuration directory to avoid changing the normal `~/.dws` login state:
|
||||
|
||||
```bash
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
|
||||
```
|
||||
|
||||
Use the same `DWS_CONFIG_DIR` for every command. Use `./dws` for a source build and `dws` for an installed release.
|
||||
|
||||
## Pre-release overrides (maintainers only)
|
||||
|
||||
Normal international users need only `--intl`; they should not set `--pre-url` or `--mcp-url`.
|
||||
|
||||
Maintainers can test the pre-release login/MCP pair with:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
```
|
||||
|
||||
A corresponding `pre-mcp.*` URL is also accepted, and DWS derives the paired `pre-login.*` / `pre-mcp.*` bases. `--mcp-url` explicitly overrides the MCP base URL for that login.
|
||||
|
||||
Pre-release services may require internal network access or allowlisted accounts. `--pre-url` is intended primarily for the MCP-managed credential flow. Do not combine it with direct custom `--client-id/--client-secret` mode unless the pre-release API contract explicitly supports that combination.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### The browser still opens a `.com` page
|
||||
|
||||
1. Run `dws auth login --help` and confirm `--intl` is present.
|
||||
2. For a source checkout, use `./dws` instead of an older installed binary.
|
||||
3. Confirm the executed command is `dws auth login --intl`.
|
||||
|
||||
### A business command appears to use the wrong region
|
||||
|
||||
Run `dws profile list --format json`, then switch with the exact `<corpId>:<userId>` selector or use the global `--profile` option. For a legacy Token created before region metadata existed, reauthorize it with `dws auth login --intl` for an international account or `dws auth login` for a domestic account.
|
||||
|
||||
### Login succeeds but the command reports missing permission
|
||||
|
||||
This normally means the organization has not enabled CLI access or the application lacks a required permission. It does not by itself indicate a region-routing failure.
|
||||
|
||||
### Should I edit `~/.dws/mcp_url` manually?
|
||||
|
||||
No. Normal users should establish the login with `dws auth login` or `dws auth login --intl`. DWS then routes official endpoints from the selected Token/profile. Manual configuration is reserved for maintainers who explicitly control the target environment.
|
||||
|
||||
## Command reference
|
||||
|
||||
| Scenario | Command |
|
||||
|---|---|
|
||||
| Domestic browser login | `dws auth login` |
|
||||
| International browser login | `dws auth login --intl` |
|
||||
| International device login | `dws auth login --intl --device` |
|
||||
| Check auth state | `dws auth status --format json` |
|
||||
| List profiles | `dws profile list --format json` |
|
||||
| Persistently switch profile | `dws profile switch <corpId>:<userId>` |
|
||||
| Toggle to previous profile | `dws profile switch -` |
|
||||
| Select a profile once | `dws --profile <corpId>:<userId> <command>` |
|
||||
@@ -0,0 +1,185 @@
|
||||
# DWS 国际版(DingTalk `.io`)使用手册
|
||||
|
||||
本手册适用于使用钉钉国际版账号登录并调用国际站服务的用户。
|
||||
|
||||
## 核心规则
|
||||
|
||||
- `dws auth login --intl` 创建或刷新国际版登录,使用 `*.dingtalk.io` 登录、鉴权和 MCP 服务。
|
||||
- 不传 `--intl` 时仍使用国内钉钉 `*.dingtalk.com`,原有链路保持不变。
|
||||
- `--intl` 只用于登录命令。登录完成后,`contact`、`calendar`、`doc` 等业务命令不需要再传该参数。
|
||||
- 每个 Token 会记录登录区域。执行业务命令时,DWS 根据当前或 `--profile` 指定的账号自动选择 `.com` 或 `.io` 网关。
|
||||
- `--international` 是 `--intl` 的兼容别名;新脚本推荐使用较短的 `--intl`。
|
||||
|
||||
## 确认当前版本支持国际版
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
dws auth login --help
|
||||
```
|
||||
|
||||
帮助中应包含:
|
||||
|
||||
```text
|
||||
--intl
|
||||
--international
|
||||
```
|
||||
|
||||
从源码分支验证时,先在仓库根目录构建,并始终使用本次构建的 `./dws`,避免误用系统中已安装的旧版本:
|
||||
|
||||
```bash
|
||||
make build
|
||||
./dws auth login --help
|
||||
```
|
||||
|
||||
## 国际版登录
|
||||
|
||||
### 浏览器登录
|
||||
|
||||
```bash
|
||||
dws auth login --intl
|
||||
```
|
||||
|
||||
DWS 会打开国际版登录页面。完成扫码或账号授权后,登录结果会保存为本机 profile。
|
||||
|
||||
### 设备码登录
|
||||
|
||||
适用于 SSH、容器或没有可用浏览器的环境:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --device
|
||||
```
|
||||
|
||||
按照终端提示,在另一台可打开浏览器的设备上完成授权。
|
||||
|
||||
### 使用自有应用凭证完成用户 OAuth
|
||||
|
||||
```bash
|
||||
dws auth login --intl \
|
||||
--client-id <APP_KEY> \
|
||||
--client-secret <APP_SECRET>
|
||||
```
|
||||
|
||||
该模式仍然需要用户在浏览器中完成 OAuth 授权,不是无用户授权的 `client_credentials` 登录。应用必须在国际版开放平台正确配置回调地址和所需权限。不要在命令历史、日志或 PR 中提交真实的 AppSecret。
|
||||
|
||||
## 验证登录和业务调用
|
||||
|
||||
查看当前登录状态:
|
||||
|
||||
```bash
|
||||
dws auth status --format json
|
||||
```
|
||||
|
||||
列出本机全部账号并找到当前 profile:
|
||||
|
||||
```bash
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
执行一个只读命令验证国际链路,例如:
|
||||
|
||||
```bash
|
||||
dws contact user get-self
|
||||
```
|
||||
|
||||
登录状态正常但业务命令提示组织未开通 CLI 时,需要由国际版组织管理员在国际版开发者平台开启 CLI 访问或完成授权审批。
|
||||
|
||||
## 国内版和国际版账号并存
|
||||
|
||||
可以在同一台机器上分别登录国内版和国际版账号:
|
||||
|
||||
```bash
|
||||
# 国内版(.com)
|
||||
dws auth login
|
||||
|
||||
# 国际版(.io)
|
||||
dws auth login --intl
|
||||
|
||||
# 查看稳定的 profile 选择器
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
持久切换账号:
|
||||
|
||||
```bash
|
||||
dws profile switch <corpId>:<userId>
|
||||
```
|
||||
|
||||
切回上一个账号:
|
||||
|
||||
```bash
|
||||
dws profile switch -
|
||||
```
|
||||
|
||||
只为单次命令指定账号,不修改默认账号:
|
||||
|
||||
```bash
|
||||
dws --profile <corpId>:<userId> contact user get-self
|
||||
```
|
||||
|
||||
DWS 会按照选中 profile 的 Token 区域自动选择 `.com` 或 `.io`,不需要在业务命令上追加 `--intl`。
|
||||
|
||||
## 使用独立配置目录进行验证
|
||||
|
||||
如果不希望测试登录影响日常使用的 `~/.dws`,可以指定独立配置目录:
|
||||
|
||||
```bash
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth login --intl
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws auth status --format json
|
||||
DWS_CONFIG_DIR=/tmp/dws-intl-smoke ./dws contact user get-self
|
||||
```
|
||||
|
||||
请在三条命令中使用同一个 `DWS_CONFIG_DIR`。验证源码分支时使用 `./dws`;验证已安装版本时可改为 `dws`。
|
||||
|
||||
## 预发参数(仅维护者)
|
||||
|
||||
普通国际版用户只需要 `--intl`,不要配置 `--pre-url` 或 `--mcp-url`。
|
||||
|
||||
维护者验证预发登录/MCP 链路时可以使用:
|
||||
|
||||
```bash
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
```
|
||||
|
||||
也可以传入对应的 `pre-mcp.*` 地址;DWS 会推导配套的 `pre-login.*` / `pre-mcp.*` 地址。`--mcp-url` 用于显式覆盖本次登录的 MCP base URL。
|
||||
|
||||
预发环境可能只对内网或特定测试账号开放。`--pre-url` 主要服务于 MCP 托管凭证登录流程;除非预发 API 契约已经明确支持,否则不要把它与自有 `--client-id/--client-secret` 直连模式组合使用。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 仍然打开 `.com` 登录页面
|
||||
|
||||
1. 运行 `dws auth login --help`,确认当前二进制包含 `--intl`。
|
||||
2. 从源码验证时使用 `./dws`,不要误用 PATH 中的旧版本。
|
||||
3. 确认实际执行的是 `dws auth login --intl`,而不是普通 `dws auth login`。
|
||||
|
||||
### 业务命令似乎使用了错误区域
|
||||
|
||||
先检查当前账号:
|
||||
|
||||
```bash
|
||||
dws profile list --format json
|
||||
```
|
||||
|
||||
然后使用精确的 `<corpId>:<userId>` 切换或通过全局 `--profile` 单次指定。对于在区域字段引入前生成的历史 Token,建议使用正确的登录方式重新授权:国际账号执行 `dws auth login --intl`,国内账号执行 `dws auth login`。
|
||||
|
||||
### 登录成功但提示没有权限
|
||||
|
||||
这通常是组织 CLI 准入或应用授权问题,不代表区域路由失败。请确认目标组织已开启 CLI 访问,并且当前应用拥有命令所需权限。
|
||||
|
||||
### 是否需要手工修改 `~/.dws/mcp_url`
|
||||
|
||||
不需要。正常使用应通过 `dws auth login` 或 `dws auth login --intl` 建立登录态;业务命令会根据选中的 Token/profile 自动路由。手工修改配置只适用于明确了解目标环境的维护者调试场景。
|
||||
|
||||
## 命令速查
|
||||
|
||||
| 场景 | 命令 |
|
||||
|---|---|
|
||||
| 国内版浏览器登录 | `dws auth login` |
|
||||
| 国际版浏览器登录 | `dws auth login --intl` |
|
||||
| 国际版设备码登录 | `dws auth login --intl --device` |
|
||||
| 查看登录状态 | `dws auth status --format json` |
|
||||
| 查看所有账号 | `dws profile list --format json` |
|
||||
| 持久切换账号 | `dws profile switch <corpId>:<userId>` |
|
||||
| 切回上一个账号 | `dws profile switch -` |
|
||||
| 单次指定账号 | `dws --profile <corpId>:<userId> <command>` |
|
||||
@@ -65,12 +65,16 @@ func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *te
|
||||
token := "token-a"
|
||||
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||
calls.Add(1)
|
||||
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: token,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
LoginRegion: string(authpkg.LoginRegionInternational),
|
||||
}, nil
|
||||
})
|
||||
|
||||
manager := NewTokenManager()
|
||||
first, err := manager.Get(context.Background(), configDir, "")
|
||||
if err != nil || first.AccessToken != "token-a" {
|
||||
if err != nil || first.AccessToken != "token-a" || first.LoginRegion != authpkg.LoginRegionInternational || !first.LoginRegionKnown {
|
||||
t.Fatalf("first token = %#v, %v", first, err)
|
||||
}
|
||||
second, err := manager.Get(context.Background(), configDir, "")
|
||||
|
||||
@@ -41,9 +41,11 @@ type accessTokenSnapshotGetter interface {
|
||||
// AccessTokenSnapshot is the minimal bearer view needed by the process cache.
|
||||
// Refresh-token material never leaves the auth package.
|
||||
type AccessTokenSnapshot struct {
|
||||
AccessToken string
|
||||
ExpiresAt time.Time
|
||||
Source string
|
||||
AccessToken string
|
||||
ExpiresAt time.Time
|
||||
Source string
|
||||
LoginRegion authpkg.LoginRegion
|
||||
LoginRegionKnown bool
|
||||
}
|
||||
|
||||
type tokenManagerKey struct {
|
||||
@@ -223,9 +225,11 @@ func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile s
|
||||
data, err := snapshotProvider.GetTokenSnapshot(ctx)
|
||||
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
|
||||
return AccessTokenSnapshot{
|
||||
AccessToken: strings.TrimSpace(data.AccessToken),
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
Source: "oauth",
|
||||
AccessToken: strings.TrimSpace(data.AccessToken),
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
Source: "oauth",
|
||||
LoginRegion: authpkg.LoginRegion(strings.TrimSpace(data.LoginRegion)),
|
||||
LoginRegionKnown: true,
|
||||
}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||
|
||||
@@ -712,9 +712,9 @@ func TestCrossPlatformCoverageAgentMetadataMCPAndPluginScoping(t *testing.T) {
|
||||
// calls must fail before preflight or transport while anonymous plugins remain
|
||||
// valid above.
|
||||
resolveCalled := false
|
||||
testseam.Swap(t, &runnerResolveAuthToken, func(*runtimeRunner, context.Context) (string, error) {
|
||||
testseam.Swap(t, &runnerResolveAuthSnapshot, func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
|
||||
resolveCalled = true
|
||||
return "", nil
|
||||
return AccessTokenSnapshot{}, nil
|
||||
})
|
||||
callsBefore := len(captured)
|
||||
unauthenticated := &runtimeRunner{
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -48,8 +50,24 @@ type authLoginConfig struct {
|
||||
TargetCorpID string
|
||||
HistoryProfileSelector string
|
||||
HistoryProfileSelectorExplicit bool
|
||||
International bool
|
||||
PreURL string
|
||||
MCPURL string
|
||||
}
|
||||
|
||||
type authLoginEndpointOverrides struct {
|
||||
LoginURL string
|
||||
MCPURL string
|
||||
}
|
||||
|
||||
type authLoginMCPPersistence uint8
|
||||
|
||||
const (
|
||||
authLoginMCPUseDefault authLoginMCPPersistence = iota
|
||||
authLoginMCPUseManagedRegion
|
||||
authLoginMCPUseExplicitOverride
|
||||
)
|
||||
|
||||
type authLoginGuideAction string
|
||||
|
||||
const (
|
||||
@@ -103,12 +121,17 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
支持的登录方式:
|
||||
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
|
||||
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
|
||||
- 自有应用 OAuth (--client-id/--client-secret): 使用指定应用完成用户授权
|
||||
- 直接提供 Token (--token): 跳过授权,使用已有 token
|
||||
|
||||
不支持的登录方式:
|
||||
- 邮箱/密码登录
|
||||
- 手机号/验证码登录
|
||||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||||
- 无用户授权的纯应用凭证 (client_credentials) 登录
|
||||
|
||||
区域:
|
||||
- 默认使用国内钉钉 .com 登录与服务端点
|
||||
- --intl(或 --international)使用国际版 .io 登录;后续业务命令按所选 profile 自动路由
|
||||
|
||||
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
@@ -116,6 +139,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
示例:
|
||||
dws auth login # 本机登录并新增/刷新一个组织 profile
|
||||
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
|
||||
dws auth login --intl # 使用钉钉国际版 .io 登录入口
|
||||
dws auth login --intl --pre-url https://pre-login.dingtalk.io
|
||||
dws auth login --intl --pre-url https://pre-mcp.dingtalk.io
|
||||
dws auth login --recommend # 无交互批量授权服务端推荐权限
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
|
||||
@@ -126,6 +152,22 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var preOverrides authLoginEndpointOverrides
|
||||
if cfg.PreURL != "" {
|
||||
var err error
|
||||
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restoreLoginBaseURL := authpkg.PushLoginBaseURLOverride(preOverrides.LoginURL)
|
||||
defer restoreLoginBaseURL()
|
||||
}
|
||||
mcpBaseURL, mcpPersistence, err := authLoginMCPBaseURLForConfig(cfg, preOverrides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restoreMCPBaseURL := authpkg.PushMCPBaseURLOverride(mcpBaseURL)
|
||||
defer restoreMCPBaseURL()
|
||||
configDir := defaultConfigDir()
|
||||
var tokenData *authpkg.TokenData
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
@@ -139,6 +181,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
AccessToken: cfg.Token,
|
||||
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
|
||||
}
|
||||
if cfg.International {
|
||||
tokenData.LoginRegion = string(authpkg.LoginRegionInternational)
|
||||
}
|
||||
if err := authSaveTokenData(configDir, tokenData); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
|
||||
}
|
||||
@@ -149,6 +194,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
if cfg.International {
|
||||
provider.SetLoginRegion(authpkg.LoginRegionInternational)
|
||||
}
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
@@ -167,6 +215,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.TargetCorpID = cfg.TargetCorpID
|
||||
if cfg.International {
|
||||
provider.LoginRegion = authpkg.LoginRegionInternational
|
||||
}
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
@@ -180,6 +231,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if tokenData != nil {
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, mcpBaseURL, mcpPersistence); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to persist MCP URL: %v", err))
|
||||
}
|
||||
}
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
@@ -278,6 +334,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
}
|
||||
cmd.Flags().String("token", "", "Access token")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||||
cmd.Flags().Bool("intl", false, "Use DingTalk international (.io) login and service endpoints")
|
||||
cmd.Flags().Bool("international", false, "Use DingTalk international (.io) login and service endpoints")
|
||||
cmd.Flags().String("pre-url", "", "Override pre-release login/MCP base URL for this login")
|
||||
cmd.Flags().String("mcp-url", "", "Override MCP base URL for this login")
|
||||
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
|
||||
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
|
||||
// Hidden compatibility flags
|
||||
@@ -967,6 +1027,7 @@ func newAuthResetCommand() *cobra.Command {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
||||
}
|
||||
_ = authRemove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = authRemove(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
|
||||
_ = authRemove(filepath.Join(configDir, "token"))
|
||||
_ = authDeleteAppConfig(configDir)
|
||||
ResetRuntimeTokenCache()
|
||||
@@ -1225,6 +1286,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --device")
|
||||
}
|
||||
intl, err := cmd.Flags().GetBool("intl")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --intl")
|
||||
}
|
||||
international, err := cmd.Flags().GetBool("international")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --international")
|
||||
}
|
||||
force, err := cmd.Flags().GetBool("force")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --force")
|
||||
@@ -1233,6 +1302,14 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
|
||||
}
|
||||
preURL, err := cmd.Flags().GetString("pre-url")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --pre-url")
|
||||
}
|
||||
mcpURL, err := cmd.Flags().GetString("mcp-url")
|
||||
if err != nil {
|
||||
return authLoginConfig{}, apperrors.NewInternal("failed to read --mcp-url")
|
||||
}
|
||||
yes := false
|
||||
profileSelector := ""
|
||||
if cmd.Root() != nil {
|
||||
@@ -1266,9 +1343,172 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
TargetCorpID: targetCorpID,
|
||||
HistoryProfileSelector: historyProfileSelector,
|
||||
HistoryProfileSelectorExplicit: historyProfileSelectorExplicit,
|
||||
International: intl || international,
|
||||
PreURL: strings.TrimSpace(preURL),
|
||||
MCPURL: strings.TrimSpace(mcpURL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func authLoginEndpointOverridesForPreURL(raw string) (authLoginEndpointOverrides, error) {
|
||||
parsed, normalized, err := normalizeAuthLoginBaseURL(raw, "--pre-url")
|
||||
if err != nil {
|
||||
return authLoginEndpointOverrides{}, err
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
switch {
|
||||
case strings.HasPrefix(host, "pre-login."):
|
||||
return authLoginEndpointOverrides{
|
||||
LoginURL: normalized,
|
||||
MCPURL: authLoginURLWithHost(parsed, "pre-mcp."+strings.TrimPrefix(host, "pre-login.")),
|
||||
}, nil
|
||||
case strings.HasPrefix(host, "pre-mcp."):
|
||||
return authLoginEndpointOverrides{
|
||||
LoginURL: authLoginURLWithHost(parsed, "pre-login."+strings.TrimPrefix(host, "pre-mcp.")),
|
||||
MCPURL: normalized,
|
||||
}, nil
|
||||
default:
|
||||
return authLoginEndpointOverrides{}, apperrors.NewValidation("--pre-url must be a pre-login.* or pre-mcp.* URL")
|
||||
}
|
||||
}
|
||||
|
||||
func authLoginMCPBaseURLForConfig(cfg authLoginConfig, preOverrides authLoginEndpointOverrides) (string, authLoginMCPPersistence, error) {
|
||||
if cfg.MCPURL != "" {
|
||||
_, normalized, err := normalizeAuthLoginBaseURL(cfg.MCPURL, "--mcp-url")
|
||||
if err != nil {
|
||||
return "", authLoginMCPUseDefault, err
|
||||
}
|
||||
return normalized, authLoginMCPUseExplicitOverride, nil
|
||||
}
|
||||
if cfg.PreURL != "" {
|
||||
if preOverrides.MCPURL == "" {
|
||||
var err error
|
||||
preOverrides, err = authLoginEndpointOverridesForPreURL(cfg.PreURL)
|
||||
if err != nil {
|
||||
return "", authLoginMCPUseDefault, err
|
||||
}
|
||||
}
|
||||
return preOverrides.MCPURL, authLoginMCPUseExplicitOverride, nil
|
||||
}
|
||||
if cfg.International {
|
||||
return authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion, nil
|
||||
}
|
||||
return authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault, nil
|
||||
}
|
||||
|
||||
func persistAuthLoginMCPBaseURL(configDir, mcpBaseURL string, persistence authLoginMCPPersistence) error {
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
|
||||
|
||||
switch persistence {
|
||||
case authLoginMCPUseExplicitOverride:
|
||||
if err := removeAuthLoginManagedMCPRegion(managedRegionPath); err != nil {
|
||||
return fmt.Errorf("clear managed MCP region: %w", err)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("save explicit MCP URL: %w", err)
|
||||
}
|
||||
return nil
|
||||
case authLoginMCPUseManagedRegion:
|
||||
managedURL, managedErr := authReadFile(managedRegionPath)
|
||||
if managedErr != nil && !os.IsNotExist(managedErr) {
|
||||
return fmt.Errorf("read managed MCP region: %w", managedErr)
|
||||
}
|
||||
currentURL, currentErr := authReadFile(mcpURLPath)
|
||||
switch {
|
||||
case currentErr == nil && os.IsNotExist(managedErr):
|
||||
return nil
|
||||
case currentErr == nil && strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)):
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
case currentErr != nil && !os.IsNotExist(currentErr):
|
||||
return fmt.Errorf("read MCP URL: %w", currentErr)
|
||||
}
|
||||
if err := authAtomicWrite(managedRegionPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("save managed MCP region: %w", err)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
_ = authRemove(managedRegionPath)
|
||||
return fmt.Errorf("save managed MCP URL: %w", err)
|
||||
}
|
||||
return nil
|
||||
case authLoginMCPUseDefault:
|
||||
managedURL, err := authReadFile(managedRegionPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read managed MCP region: %w", err)
|
||||
}
|
||||
currentURL, err := authReadFile(mcpURLPath)
|
||||
if os.IsNotExist(err) {
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read MCP URL: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(string(currentURL)) != strings.TrimSpace(string(managedURL)) {
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
}
|
||||
if err := authAtomicWrite(mcpURLPath, []byte(mcpBaseURL), config.FilePerm); err != nil {
|
||||
return fmt.Errorf("restore default MCP URL: %w", err)
|
||||
}
|
||||
return removeAuthLoginManagedMCPRegion(managedRegionPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported MCP persistence mode %d", persistence)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAuthLoginManagedMCPRegion(path string) error {
|
||||
if err := authRemove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeAuthLoginBaseURL(raw, flagName string) (*url.URL, string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " cannot be empty")
|
||||
}
|
||||
if !strings.Contains(value, "://") {
|
||||
value = "https://" + value
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return nil, "", apperrors.NewValidation(fmt.Sprintf("invalid %s: %v", flagName, err))
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must use http or https")
|
||||
}
|
||||
if parsed.Hostname() == "" {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must include a host")
|
||||
}
|
||||
if parsed.Scheme == "http" && !isAuthLoginLoopbackHost(parsed.Hostname()) {
|
||||
return nil, "", apperrors.NewValidation(flagName + " must use HTTPS, except for a loopback HTTP test endpoint")
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
return parsed, strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func isAuthLoginLoopbackHost(host string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(strings.TrimSpace(host))
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func authLoginURLWithHost(parsed *url.URL, host string) string {
|
||||
copyURL := *parsed
|
||||
if port := parsed.Port(); port != "" {
|
||||
copyURL.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
copyURL.Host = host
|
||||
}
|
||||
return strings.TrimRight(copyURL.String(), "/")
|
||||
}
|
||||
|
||||
func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -214,7 +215,8 @@ func TestCrossPlatformCoverageAuthCoverageFormsParentAndTargets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
oldSave := authSaveTokenData
|
||||
oldDevice := authDeviceLogin
|
||||
oldOAuth := authOAuthLogin
|
||||
@@ -255,6 +257,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) {
|
||||
t.Fatalf("json token login = %q, %v", out, err)
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://example.com"}); err == nil {
|
||||
t.Fatal("invalid pre-release host should fail")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "mcp-url": "http://remote.example.com"}); err == nil {
|
||||
t.Fatal("remote plaintext MCP URL should fail")
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "pre-url": "https://pre-login.dingtalk.io"}); err != nil {
|
||||
t.Fatalf("pre-release token login = %v", err)
|
||||
}
|
||||
|
||||
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("device")
|
||||
@@ -271,6 +282,15 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) {
|
||||
if provider.LoginRegion != authpkg.LoginRegionInternational {
|
||||
t.Errorf("device login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "intl": "true"}); err != nil {
|
||||
t.Fatalf("international device login = %v", err)
|
||||
}
|
||||
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
return nil, errors.New("oauth")
|
||||
@@ -291,6 +311,25 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") {
|
||||
t.Fatalf("oauth success = %q, %v", out, err)
|
||||
}
|
||||
authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) {
|
||||
if provider.LoginRegion != authpkg.LoginRegionInternational {
|
||||
t.Errorf("OAuth login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"intl": "true"}); err != nil {
|
||||
t.Fatalf("international OAuth login = %v", err)
|
||||
}
|
||||
|
||||
blockedConfigDir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(blockedConfigDir, "mcp_url"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", blockedConfigDir)
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token", "intl": "true"}); err == nil || !strings.Contains(err.Error(), "failed to persist MCP URL") {
|
||||
t.Fatalf("MCP URL persist failure = %v", err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
|
||||
return errors.New("recommend")
|
||||
@@ -1418,7 +1457,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
||||
authRemove = func(string) error { removed++; return errors.New("ignored") }
|
||||
authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") }
|
||||
edition.Override(&edition.Hooks{})
|
||||
if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") {
|
||||
if err := reset.RunE(reset, nil); err != nil || removed != 4 || !strings.Contains(out.String(), "重新登录") {
|
||||
t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed)
|
||||
}
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||
|
||||
@@ -33,6 +33,8 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1033,8 +1035,12 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := root.PersistentFlags().Set("yes", "true"); err != nil {
|
||||
@@ -1061,6 +1067,560 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsInternationalAliases(t *testing.T) {
|
||||
for _, flag := range []string{"intl", "international"} {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set(flag, "true"); err != nil {
|
||||
t.Fatalf("set %s: %v", flag, err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if !cfg.International {
|
||||
t.Fatalf("International = false for --%s, want true", flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
}{
|
||||
{
|
||||
name: "missing intl",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing international",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing pre url",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
cmd.Flags().Bool("international", false, "")
|
||||
cmd.Flags().Bool("force", false, "")
|
||||
cmd.Flags().Bool("recommend", false, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing mcp url",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().Bool("device", false, "")
|
||||
cmd.Flags().Bool("intl", false, "")
|
||||
cmd.Flags().Bool("international", false, "")
|
||||
cmd.Flags().Bool("force", false, "")
|
||||
cmd.Flags().Bool("recommend", false, "")
|
||||
cmd.Flags().String("pre-url", "", "")
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "login"}
|
||||
tc.setup(cmd)
|
||||
if _, err := resolveAuthLoginConfig(cmd); err == nil {
|
||||
t.Fatal("resolveAuthLoginConfig succeeded with an incomplete flag set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsMCPURL(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set("mcp-url", " https://pre-mcp.dingtalk.io/ "); err != nil {
|
||||
t.Fatalf("set mcp-url: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if cfg.MCPURL != "https://pre-mcp.dingtalk.io/" {
|
||||
t.Fatalf("MCPURL = %q, want trimmed flag value", cfg.MCPURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveAuthLoginConfigReadsPreURL(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
login := &cobra.Command{Use: "login"}
|
||||
login.Flags().String("token", "", "")
|
||||
login.Flags().Bool("device", false, "")
|
||||
login.Flags().Bool("intl", false, "")
|
||||
login.Flags().Bool("international", false, "")
|
||||
login.Flags().Bool("force", false, "")
|
||||
login.Flags().Bool("recommend", false, "")
|
||||
login.Flags().String("pre-url", "", "")
|
||||
login.Flags().String("mcp-url", "", "")
|
||||
root.AddCommand(login)
|
||||
|
||||
if err := login.Flags().Set("pre-url", " pre-login.dingtalk.io "); err != nil {
|
||||
t.Fatalf("set pre-url: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := resolveAuthLoginConfig(login)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAuthLoginConfig error = %v", err)
|
||||
}
|
||||
if cfg.PreURL != "pre-login.dingtalk.io" {
|
||||
t.Fatalf("PreURL = %q, want trimmed flag value", cfg.PreURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginEndpointOverridesForPreURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantLogin string
|
||||
wantMCP string
|
||||
}{
|
||||
{
|
||||
name: "pre login",
|
||||
raw: "https://pre-login.dingtalk.io/",
|
||||
wantLogin: "https://pre-login.dingtalk.io",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
{
|
||||
name: "pre mcp",
|
||||
raw: "pre-mcp.dingtalk.io",
|
||||
wantLogin: "https://pre-login.dingtalk.io",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
{
|
||||
name: "pre login with port",
|
||||
raw: "https://pre-login.dingtalk.io:8443/path/",
|
||||
wantLogin: "https://pre-login.dingtalk.io:8443/path",
|
||||
wantMCP: "https://pre-mcp.dingtalk.io:8443/path",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := authLoginEndpointOverridesForPreURL(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginEndpointOverridesForPreURL error = %v", err)
|
||||
}
|
||||
if got.LoginURL != tc.wantLogin || got.MCPURL != tc.wantMCP {
|
||||
t.Fatalf("overrides = %#v, want login %q mcp %q", got, tc.wantLogin, tc.wantMCP)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, raw := range []string{"https://example.com", "http://pre-login.example.com"} {
|
||||
if _, err := authLoginEndpointOverridesForPreURL(raw); err == nil {
|
||||
t.Fatalf("authLoginEndpointOverridesForPreURL(%q) succeeded", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginMCPBaseURLForConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg authLoginConfig
|
||||
preOverride authLoginEndpointOverrides
|
||||
wantURL string
|
||||
wantPersistence authLoginMCPPersistence
|
||||
}{
|
||||
{
|
||||
name: "default center login uses com and resets only managed region",
|
||||
cfg: authLoginConfig{},
|
||||
wantURL: authpkg.DefaultMCPBaseURL,
|
||||
wantPersistence: authLoginMCPUseDefault,
|
||||
},
|
||||
{
|
||||
name: "international login persists managed io",
|
||||
cfg: authLoginConfig{International: true},
|
||||
wantURL: authpkg.InternationalMCPBaseURL,
|
||||
wantPersistence: authLoginMCPUseManagedRegion,
|
||||
},
|
||||
{
|
||||
name: "pre login persists mapped pre mcp",
|
||||
cfg: authLoginConfig{PreURL: "pre-login.dingtalk.io"},
|
||||
preOverride: authLoginEndpointOverrides{
|
||||
LoginURL: "https://pre-login.dingtalk.io",
|
||||
MCPURL: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
wantURL: "https://pre-mcp.dingtalk.io",
|
||||
wantPersistence: authLoginMCPUseExplicitOverride,
|
||||
},
|
||||
{
|
||||
name: "explicit mcp url wins over pre url",
|
||||
cfg: authLoginConfig{
|
||||
PreURL: "pre-login.dingtalk.io",
|
||||
MCPURL: " https://custom-mcp.example.com/ ",
|
||||
},
|
||||
preOverride: authLoginEndpointOverrides{
|
||||
LoginURL: "https://pre-login.dingtalk.io",
|
||||
MCPURL: "https://pre-mcp.dingtalk.io",
|
||||
},
|
||||
wantURL: "https://custom-mcp.example.com",
|
||||
wantPersistence: authLoginMCPUseExplicitOverride,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotURL, gotPersistence, err := authLoginMCPBaseURLForConfig(tc.cfg, tc.preOverride)
|
||||
if err != nil {
|
||||
t.Fatalf("authLoginMCPBaseURLForConfig error = %v", err)
|
||||
}
|
||||
if gotURL != tc.wantURL || gotPersistence != tc.wantPersistence {
|
||||
t.Fatalf("got url=%q persistence=%v, want url=%q persistence=%v", gotURL, gotPersistence, tc.wantURL, tc.wantPersistence)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, cfg := range []authLoginConfig{
|
||||
{MCPURL: "http://remote.example.com"},
|
||||
{PreURL: "https://example.com"},
|
||||
} {
|
||||
if _, _, err := authLoginMCPBaseURLForConfig(cfg, authLoginEndpointOverrides{}); err == nil {
|
||||
t.Fatalf("authLoginMCPBaseURLForConfig(%#v) succeeded", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURL(t *testing.T) {
|
||||
t.Run("persists selected io mcp url", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if string(data) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("mcp_url = %q, want %q", string(data), authpkg.InternationalMCPBaseURL)
|
||||
}
|
||||
managed, err := os.ReadFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName))
|
||||
if err != nil || string(managed) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("managed MCP region = %q, %v", string(managed), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("center login preserves previous persisted override", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
const customURL = "https://custom-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatalf("persistAuthLoginMCPBaseURL error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if string(data) != customURL {
|
||||
t.Fatalf("mcp_url = %q, want preserved override %q", string(data), customURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("international login preserves an unmanaged explicit override", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("unmanaged override acquired a managed marker: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("center login resets a managed international URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != authpkg.DefaultMCPBaseURL {
|
||||
t.Fatalf("mcp_url = %q, %v; want domestic default", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed region marker remains after domestic login: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit override clears region ownership", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const customURL = "https://custom-mcp.example.com"
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, customURL, authLoginMCPUseExplicitOverride); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale marker never deletes a different custom URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://custom.example.com"), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, config.ManagedMCPURLRegionFileName), []byte(authpkg.InternationalMCPBaseURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
|
||||
if err != nil || string(data) != "https://custom.example.com" {
|
||||
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("international login clears a stale marker without changing a custom URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
managedRegionPath := filepath.Join(configDir, config.ManagedMCPURLRegionFileName)
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(mcpURLPath, []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(managedRegionPath, []byte(authpkg.DefaultMCPBaseURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := persistAuthLoginMCPBaseURL(configDir, authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(mcpURLPath)
|
||||
if err != nil || string(data) != customURL {
|
||||
t.Fatalf("custom mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(managedRegionPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale managed marker remains: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersistAuthLoginMCPBaseURLErrors(t *testing.T) {
|
||||
fail := errors.New("persist failure")
|
||||
|
||||
t.Run("explicit marker cleanup", func(t *testing.T) {
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
|
||||
t.Fatalf("explicit marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit URL write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), "https://custom.example.com", authLoginMCPUseExplicitOverride); !errors.Is(err, fail) {
|
||||
t.Fatalf("explicit URL write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed marker write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed marker read", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed MCP URL read", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed MCP URL read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed stale marker cleanup", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.DefaultMCPBaseURL), nil
|
||||
}
|
||||
return []byte("https://private-mcp.example.com"), nil
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed stale marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("managed URL write cleans marker", func(t *testing.T) {
|
||||
writes := 0
|
||||
removed := false
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error {
|
||||
writes++
|
||||
if writes == 2 {
|
||||
return fail
|
||||
}
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { removed = true; return nil })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.InternationalMCPBaseURL, authLoginMCPUseManagedRegion); !errors.Is(err, fail) || !removed {
|
||||
t.Fatalf("managed URL write error = %v, marker removed=%v", err, removed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default managed marker read", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return nil, fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("managed marker read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing MCP URL cleans marker", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.InternationalMCPBaseURL), nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
testseam.Swap(t, &authRemove, func(string) error { return nil })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); err != nil {
|
||||
t.Fatalf("missing MCP URL cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCP URL read", func(t *testing.T) {
|
||||
reads := 0
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return []byte(authpkg.InternationalMCPBaseURL), nil
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("MCP URL read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default URL write", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("default URL write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("final marker cleanup", func(t *testing.T) {
|
||||
testseam.Swap(t, &authReadFile, func(string) ([]byte, error) { return []byte(authpkg.InternationalMCPBaseURL), nil })
|
||||
testseam.Swap(t, &authAtomicWrite, func(string, []byte, os.FileMode) error { return nil })
|
||||
testseam.Swap(t, &authRemove, func(string) error { return fail })
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPUseDefault); !errors.Is(err, fail) {
|
||||
t.Fatalf("final marker cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := persistAuthLoginMCPBaseURL(t.TempDir(), authpkg.DefaultMCPBaseURL, authLoginMCPPersistence(255)); err == nil {
|
||||
t.Fatal("unsupported MCP persistence mode succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNormalizeAuthLoginBaseURLTransportSecurity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantURL string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "https remote", raw: "https://pre-mcp.example.com/path/?secret=drop#fragment", wantURL: "https://pre-mcp.example.com/path"},
|
||||
{name: "http localhost", raw: "http://localhost:8080/", wantURL: "http://localhost:8080"},
|
||||
{name: "http IPv4 loopback", raw: "http://127.0.0.1:8080", wantURL: "http://127.0.0.1:8080"},
|
||||
{name: "http IPv6 loopback", raw: "http://[::1]:8080", wantURL: "http://[::1]:8080"},
|
||||
{name: "http remote", raw: "http://pre-mcp.example.com", wantErr: "must use HTTPS"},
|
||||
{name: "empty", raw: " ", wantErr: "cannot be empty"},
|
||||
{name: "invalid URL", raw: "https://%", wantErr: "invalid --mcp-url"},
|
||||
{name: "invalid scheme", raw: "ftp://pre-mcp.example.com", wantErr: "must use http or https"},
|
||||
{name: "missing host", raw: "https:///path", wantErr: "must include a host"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, got, err := normalizeAuthLoginBaseURL(tc.raw, "--mcp-url")
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("normalizeAuthLoginBaseURL error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeAuthLoginBaseURL error = %v", err)
|
||||
}
|
||||
if got != tc.wantURL {
|
||||
t.Fatalf("normalized URL = %q, want %q", got, tc.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
|
||||
if !authLoginForcesAuthorization(authLoginConfig{}) {
|
||||
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -326,6 +327,29 @@ func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) })
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
region authpkg.LoginRegion
|
||||
want string
|
||||
}{
|
||||
{raw: "%", want: "%"},
|
||||
{raw: "https://dingtalk.io/path", want: "https://dingtalk.com/path"},
|
||||
{raw: "https://mcp.dingtalk.com:8443/path", region: authpkg.LoginRegionInternational, want: "https://mcp.dingtalk.io:8443/path"},
|
||||
} {
|
||||
if got := mcpBaseURLForLoginRegion(tc.raw, tc.region); got != tc.want {
|
||||
t.Fatalf("mcpBaseURLForLoginRegion(%q, %q) = %q, want %q", tc.raw, tc.region, got, tc.want)
|
||||
}
|
||||
}
|
||||
if hasDirectRuntimeEndpointOverride("") {
|
||||
t.Fatal("blank product unexpectedly has an endpoint override")
|
||||
}
|
||||
t.Setenv("DINGTALK_COVERAGE_PRODUCT_MCP_URL", "https://override.test")
|
||||
if !hasDirectRuntimeEndpointOverride("coverage-product") {
|
||||
t.Fatal("configured product endpoint override was not detected")
|
||||
}
|
||||
if got := activeDingTalkGatewayEndpointWithBase("https://mcp-gw.dingtalk.com/server/contact", "%"); got != "https://mcp-gw.dingtalk.com/server/contact" {
|
||||
t.Fatalf("invalid gateway base rewrote endpoint to %q", got)
|
||||
}
|
||||
server := mcptypes.ServerDescriptor{
|
||||
Endpoint: "https://one.test",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
@@ -913,12 +937,21 @@ func TestCrossPlatformCoverageAuthCommandPureCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
oldInteractive := authLoginInteractiveTerminal
|
||||
authLoginInteractiveTerminal = func() bool { return false }
|
||||
t.Cleanup(func() { authLoginInteractiveTerminal = oldInteractive; authpkg.SetRuntimeProfile("") })
|
||||
for _, format := range []string{"table", "json"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
for _, tc := range []struct {
|
||||
format string
|
||||
international bool
|
||||
}{
|
||||
{format: "table"},
|
||||
{format: "json", international: true},
|
||||
} {
|
||||
t.Run(tc.format, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
@@ -929,16 +962,90 @@ func TestCrossPlatformCoverageAuthLoginTokenCommandCoverage(t *testing.T) {
|
||||
root.SetOut(&output)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"login", "--token", "manual-token", "--yes"}
|
||||
if format == "json" {
|
||||
if tc.international {
|
||||
args = append(args, "--intl")
|
||||
}
|
||||
if tc.format == "json" {
|
||||
args = append(args, "--format", "json")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil || output.Len() == 0 {
|
||||
t.Fatalf("token login = %q %v", output.String(), err)
|
||||
}
|
||||
data, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData error = %v", err)
|
||||
}
|
||||
wantRegion := ""
|
||||
if tc.international {
|
||||
wantRegion = string(authpkg.LoginRegionInternational)
|
||||
}
|
||||
if data.LoginRegion != wantRegion {
|
||||
t.Fatalf("LoginRegion = %q, want %q", data.LoginRegion, wantRegion)
|
||||
}
|
||||
if tc.international {
|
||||
snapshot, err := resolveAccessTokenSnapshotFromDir(context.Background(), configDir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAccessTokenSnapshotFromDir error = %v", err)
|
||||
}
|
||||
gotEndpoint := activeDingTalkGatewayEndpointForLoginRegion(
|
||||
"https://mcp-gw.dingtalk.com/server/contact",
|
||||
snapshot.LoginRegion,
|
||||
)
|
||||
if wantEndpoint := "https://mcp-gw.dingtalk.io/server/contact"; gotEndpoint != wantEndpoint {
|
||||
t.Fatalf("international manual-token endpoint = %q, want %q", gotEndpoint, wantEndpoint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("international then domestic login restores domestic MCP URL", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
runLogin := func(international bool) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
root.AddCommand(newAuthLoginCommand(nil))
|
||||
args := []string{"login", "--token", "manual-token", "--yes"}
|
||||
if international {
|
||||
args = append(args, "--intl")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("international=%v login error = %v", international, err)
|
||||
}
|
||||
}
|
||||
|
||||
runLogin(true)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.InternationalMCPBaseURL {
|
||||
t.Fatalf("international mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
runLogin(false)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != authpkg.DefaultMCPBaseURL {
|
||||
t.Fatalf("domestic mcp_url = %q, %v", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed MCP region marker remains: %v", err)
|
||||
}
|
||||
|
||||
const customURL = "https://private-mcp.example.com"
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(customURL), config.FilePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runLogin(true)
|
||||
if data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")); err != nil || string(data) != customURL {
|
||||
t.Fatalf("explicit mcp_url after international login = %q, %v; want preserved custom URL", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, config.ManagedMCPURLRegionFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("explicit mcp_url acquired a managed marker: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, hidden := range []bool{false, true} {
|
||||
old := edition.Get()
|
||||
edition.Override(&edition.Hooks{HideAuthLogin: hidden})
|
||||
|
||||
@@ -74,6 +74,20 @@ func defaultPATMCPEndpoint() string {
|
||||
|
||||
func defaultPATGatewayBaseURL() string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
return mcpGatewayBaseURL(raw)
|
||||
}
|
||||
|
||||
func defaultPATGatewayBaseURLForLoginRegion(region authpkg.LoginRegion) string {
|
||||
raw := strings.TrimSpace(authpkg.GetMCPBaseURL())
|
||||
if override := authpkg.MCPBaseURLOverride(); override != "" {
|
||||
raw = override
|
||||
} else {
|
||||
raw = mcpBaseURLForLoginRegion(raw, region)
|
||||
}
|
||||
return mcpGatewayBaseURL(raw)
|
||||
}
|
||||
|
||||
func mcpGatewayBaseURL(raw string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return strings.TrimRight(raw, "/")
|
||||
@@ -100,6 +114,33 @@ func defaultPATGatewayBaseURL() string {
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
func mcpBaseURLForLoginRegion(raw string, region authpkg.LoginRegion) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return raw
|
||||
}
|
||||
host := strings.ToLower(parsed.Hostname())
|
||||
fromSuffix, toSuffix := ".dingtalk.io", ".dingtalk.com"
|
||||
if region.IsInternational() {
|
||||
fromSuffix, toSuffix = toSuffix, fromSuffix
|
||||
}
|
||||
bareFrom := strings.TrimPrefix(fromSuffix, ".")
|
||||
if host != bareFrom && !strings.HasSuffix(host, fromSuffix) {
|
||||
return raw
|
||||
}
|
||||
if host == bareFrom {
|
||||
host = strings.TrimPrefix(toSuffix, ".")
|
||||
} else {
|
||||
host = strings.TrimSuffix(host, fromSuffix) + toSuffix
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsed.Host = net.JoinHostPort(host, port)
|
||||
} else {
|
||||
parsed.Host = host
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// SetDynamicServers injects server data discovered from servers.json.
|
||||
// All product endpoints are resolved dynamically from this data.
|
||||
func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
@@ -131,7 +172,7 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
@@ -262,6 +303,19 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func hasDirectRuntimeEndpointOverride(productID string) bool {
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := productEndpointOverride(candidate); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func editionServerEndpoint(productID string) (string, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
if productID == "" {
|
||||
@@ -282,7 +336,7 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
|
||||
return "", false
|
||||
}
|
||||
for _, server := range fn() {
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
endpoint := activeDingTalkGatewayEndpoint(server.Endpoint)
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
@@ -298,6 +352,46 @@ func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo
|
||||
return "", false
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpoint(endpoint string) string {
|
||||
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURL())
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpointForLoginRegion(endpoint string, region authpkg.LoginRegion) string {
|
||||
return activeDingTalkGatewayEndpointWithBase(endpoint, defaultPATGatewayBaseURLForLoginRegion(region))
|
||||
}
|
||||
|
||||
func activeDingTalkGatewayEndpointWithBase(endpoint, gatewayBaseURL string) string {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return endpoint
|
||||
}
|
||||
if !isDingTalkMCPGatewayHost(parsed.Hostname()) {
|
||||
return endpoint
|
||||
}
|
||||
base, err := url.Parse(gatewayBaseURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return endpoint
|
||||
}
|
||||
parsed.Scheme = base.Scheme
|
||||
parsed.Host = base.Host
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
func isDingTalkMCPGatewayHost(host string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(host)) {
|
||||
case "mcp-gw.dingtalk.com", "pre-mcp-gw.dingtalk.com", "mcp-gw.dingtalk.io", "pre-mcp-gw.dingtalk.io":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isDingTalkMCPGatewayEndpoint(endpoint string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(endpoint))
|
||||
return err == nil && isDingTalkMCPGatewayHost(parsed.Hostname())
|
||||
}
|
||||
|
||||
// DirectRuntimeProductIDs returns product IDs that should stay visible for
|
||||
// direct runtime execution. Dynamic products come from MCP discovery/plugin
|
||||
// registration; built-in helper products such as devapp resolve their endpoint
|
||||
|
||||
@@ -479,7 +479,11 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
|
||||
if err != nil {
|
||||
t.Fatalf("alias execution failed: %v", err)
|
||||
}
|
||||
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
if alias == "open-conversation-id" {
|
||||
if ctx == nil || len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
@@ -93,8 +94,8 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
}{
|
||||
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command := NewRootCommand()
|
||||
command.SilenceErrors = true
|
||||
@@ -488,6 +489,80 @@ func TestInjectStaticServersMergesStaticAndSupplementServers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStaticDingTalkEndpointsFollowConfiguredMCPBaseURL(t *testing.T) {
|
||||
previous := edition.Get()
|
||||
defer edition.Override(previous)
|
||||
defer SetDynamicServers(nil)
|
||||
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{
|
||||
Name: "test",
|
||||
StaticServers: func() []edition.ServerInfo {
|
||||
return []edition.ServerInfo{{
|
||||
ID: "contact",
|
||||
Name: "Contact",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/contact?key=abc",
|
||||
Prefixes: []string{"user"},
|
||||
}}
|
||||
},
|
||||
})
|
||||
|
||||
injectStaticServers()
|
||||
|
||||
for _, productID := range []string{"contact", "user"} {
|
||||
got, ok := directRuntimeEndpoint(productID, "")
|
||||
want := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
|
||||
if !ok || got != want {
|
||||
t.Fatalf("directRuntimeEndpoint(%q) = %q, %v; want %q, true", productID, got, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDingTalkEndpointsFollowSelectedTokenRegion(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
mcpURLPath := filepath.Join(configDir, "mcp_url")
|
||||
|
||||
if err := os.WriteFile(mcpURLPath, []byte("https://pre-mcp.dingtalk.io\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
endpoint := "https://pre-mcp-gw.dingtalk.io/server/contact?key=abc"
|
||||
if got, want := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionDefault), "https://pre-mcp-gw.dingtalk.com/server/contact?key=abc"; got != want {
|
||||
t.Fatalf("domestic profile endpoint = %q, want %q", got, want)
|
||||
}
|
||||
if got := activeDingTalkGatewayEndpointForLoginRegion(endpoint, authpkg.LoginRegionInternational); got != endpoint {
|
||||
t.Fatalf("international profile endpoint = %q, want %q", got, endpoint)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(mcpURLPath, []byte("https://mcp.dingtalk.com\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
if got, want := activeDingTalkGatewayEndpointForLoginRegion("https://mcp-gw.dingtalk.com/server/contact", authpkg.LoginRegionInternational), "https://mcp-gw.dingtalk.io/server/contact"; got != want {
|
||||
t.Fatalf("international profile endpoint from domestic config = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDingTalkEndpointUsesLoginScopedMCPOverride(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
restore := authpkg.PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io")
|
||||
defer restore()
|
||||
|
||||
got := activeDingTalkGatewayEndpointForLoginRegion(
|
||||
"https://mcp-gw.dingtalk.com/server/contact",
|
||||
authpkg.LoginRegionDefault,
|
||||
)
|
||||
want := "https://pre-mcp-gw.dingtalk.io/server/contact"
|
||||
if got != want {
|
||||
t.Fatalf("login-scoped endpoint = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func mustFindCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := root
|
||||
|
||||
+29
-12
@@ -162,7 +162,7 @@ var (
|
||||
runnerResolveMultiProfileSelections = resolveMultiProfileSelections
|
||||
runnerResolveProfile = authpkg.ResolveProfile
|
||||
runnerGetCachedRuntimeToken = getCachedRuntimeToken
|
||||
runnerResolveAuthToken = (*runtimeRunner).resolveAuthToken
|
||||
runnerResolveAuthSnapshot = (*runtimeRunner).resolveAuthSnapshot
|
||||
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
|
||||
runnerCallTool = (*transport.Client).CallTool
|
||||
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
|
||||
@@ -552,11 +552,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
if hasPluginAuth {
|
||||
authToken = pluginAuth.Token
|
||||
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
|
||||
var tokenErr error
|
||||
authToken, tokenErr = runnerResolveAuthToken(r, ctx)
|
||||
snapshot, tokenErr := runnerResolveAuthSnapshot(r, ctx)
|
||||
if tokenErr != nil {
|
||||
return executor.Result{}, tokenResolutionError(tokenErr)
|
||||
}
|
||||
authToken = snapshot.AccessToken
|
||||
if !hasDirectRuntimeEndpointOverride(invocation.CanonicalProduct) &&
|
||||
isDingTalkMCPGatewayEndpoint(endpoint) &&
|
||||
(snapshot.LoginRegionKnown || authpkg.MCPBaseURLOverride() != "") {
|
||||
endpoint = activeDingTalkGatewayEndpointForLoginRegion(endpoint, snapshot.LoginRegion)
|
||||
}
|
||||
}
|
||||
|
||||
var timeoutSec int
|
||||
@@ -877,21 +882,33 @@ func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
return resolveRuntimeAuthToken(ctx, explicitToken)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
|
||||
snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
|
||||
snapshot, err := r.resolveAuthSnapshot(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) resolveAuthSnapshot(ctx context.Context) (AccessTokenSnapshot, error) {
|
||||
explicitToken := ""
|
||||
if r != nil && r.globalFlags != nil {
|
||||
explicitToken = r.globalFlags.Token
|
||||
}
|
||||
return resolveRuntimeAuthSnapshot(ctx, explicitToken)
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
|
||||
snapshot, err := resolveRuntimeAuthSnapshot(ctx, explicitToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return snapshot.AccessToken, nil
|
||||
}
|
||||
|
||||
func resolveRuntimeAuthSnapshot(ctx context.Context, explicitToken string) (AccessTokenSnapshot, error) {
|
||||
return runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
|
||||
}
|
||||
|
||||
// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The
|
||||
// cache itself lives exclusively in TokenManager.
|
||||
func getCachedRuntimeToken(ctx context.Context) (string, error) {
|
||||
|
||||
@@ -127,12 +127,14 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldEdition := edition.Get()
|
||||
oldPreflight := runnerPreflightDocDownload
|
||||
oldCall := runnerCallTool
|
||||
oldHandle := runnerHandlePatAuthCheck
|
||||
oldRetry := runnerRetryWithPatAuthRetry
|
||||
oldCapture := runnerCaptureRuntimeFailure
|
||||
oldResolveSnapshot := runnerResolveAuthSnapshot
|
||||
t.Cleanup(func() {
|
||||
edition.Override(oldEdition)
|
||||
runnerPreflightDocDownload = oldPreflight
|
||||
@@ -140,6 +142,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
runnerHandlePatAuthCheck = oldHandle
|
||||
runnerRetryWithPatAuthRetry = oldRetry
|
||||
runnerCaptureRuntimeFailure = oldCapture
|
||||
runnerResolveAuthSnapshot = oldResolveSnapshot
|
||||
})
|
||||
|
||||
pluginAuthMu.Lock()
|
||||
@@ -168,6 +171,27 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
||||
if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("default auth execution = %#v, %v", got, err)
|
||||
}
|
||||
runnerResolveAuthSnapshot = func(*runtimeRunner, context.Context) (AccessTokenSnapshot, error) {
|
||||
return AccessTokenSnapshot{
|
||||
AccessToken: "international-token",
|
||||
LoginRegion: authpkg.LoginRegionInternational,
|
||||
LoginRegionKnown: true,
|
||||
}, nil
|
||||
}
|
||||
successfulCall := runnerCallTool
|
||||
routedEndpoint := ""
|
||||
runnerCallTool = func(_ *transport.Client, _ context.Context, endpoint, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
||||
routedEndpoint = endpoint
|
||||
return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil
|
||||
}
|
||||
if _, err := r.executeInvocation(context.Background(), "https://mcp-gw.dingtalk.com/server/contact", inv); err != nil {
|
||||
t.Fatalf("international auth execution = %v", err)
|
||||
}
|
||||
if routedEndpoint != "https://mcp-gw.dingtalk.io/server/contact" {
|
||||
t.Fatalf("international routed endpoint = %q", routedEndpoint)
|
||||
}
|
||||
runnerResolveAuthSnapshot = oldResolveSnapshot
|
||||
runnerCallTool = successfulCall
|
||||
|
||||
wantErr := errors.New("preflight")
|
||||
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||
@@ -362,6 +386,12 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" {
|
||||
t.Fatalf("runtime explicit token = %q, %v", got, err)
|
||||
}
|
||||
edition.Override(&edition.Hooks{TokenProvider: func(context.Context, func() (string, error)) (string, error) {
|
||||
return "", errors.New("snapshot failed")
|
||||
}})
|
||||
if _, err := r.resolveAuthToken(context.Background()); err == nil || !strings.Contains(err.Error(), "snapshot failed") {
|
||||
t.Fatalf("resolveAuthToken error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(envDWSChannel, "channel")
|
||||
edition.Override(&edition.Hooks{
|
||||
|
||||
@@ -207,7 +207,11 @@ func assertChatCatalogCompleteLeafContracts(t testing.TB) {
|
||||
})
|
||||
|
||||
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
|
||||
assertSchemaLeafParameterRequired(t, auditJoin, "chat group audit-join-validation", "conversation-id", true)
|
||||
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
|
||||
if parameters := schemaContractMap(auditJoin["parameters"]); parameters["group"] != nil {
|
||||
t.Fatalf("chat group audit-join-validation publishes hidden --group alias: %#v", parameters["group"])
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
|
||||
|
||||
@@ -424,6 +424,87 @@ func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBuildAuthURLForInternationalRegion(t *testing.T) {
|
||||
authURL := buildAuthURLForRegion("client-id", "http://127.0.0.1:1234/callback", "", LoginRegionInternational)
|
||||
if !strings.HasPrefix(authURL, InternationalAuthorizeURL+"?") {
|
||||
t.Fatalf("auth URL = %s, want international authorize host", authURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNotEnabledHTMLUsesRegionAwareAuthorizeURL(t *testing.T) {
|
||||
if !strings.Contains(notEnabledHTML, "status.authorizeUrl") {
|
||||
t.Fatal("not-enabled page must read the authorize URL from the regional login status")
|
||||
}
|
||||
if strings.Contains(notEnabledHTML, `"https://login.dingtalk.com/oauth2/auth?client_id="`) {
|
||||
t.Fatal("not-enabled page must not hard-code the domestic authorize URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoginRegionEndpointDefaults(t *testing.T) {
|
||||
if got := AuthorizeURLForLoginRegion(LoginRegionDefault); got != AuthorizeURL {
|
||||
t.Fatalf("default authorize URL = %q, want %q", got, AuthorizeURL)
|
||||
}
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionDefault); got != DefaultDeviceBaseURL {
|
||||
t.Fatalf("default device base URL = %q, want %q", got, DefaultDeviceBaseURL)
|
||||
}
|
||||
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != InternationalUserAccessTokenURL {
|
||||
t.Fatalf("international user access token URL = %q, want %q", got, InternationalUserAccessTokenURL)
|
||||
}
|
||||
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != InternationalMCPBaseURL {
|
||||
t.Fatalf("international MCP base URL = %q, want %q", got, InternationalMCPBaseURL)
|
||||
}
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != InternationalDeviceBaseURL {
|
||||
t.Fatalf("international device base URL = %q, want %q", got, InternationalDeviceBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthProviderLoginRegionHelpers(t *testing.T) {
|
||||
var nilProvider *OAuthProvider
|
||||
if got := nilProvider.loginRegion(); got != LoginRegionDefault {
|
||||
t.Fatalf("nil provider login region = %q", got)
|
||||
}
|
||||
nilProvider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
|
||||
nilProvider.applyLoginRegionToToken(&TokenData{})
|
||||
|
||||
provider := &OAuthProvider{}
|
||||
provider.useTokenLoginRegion(nil)
|
||||
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionInternational)})
|
||||
if provider.LoginRegion != LoginRegionInternational {
|
||||
t.Fatalf("provider login region = %q, want international", provider.LoginRegion)
|
||||
}
|
||||
provider.useTokenLoginRegion(&TokenData{LoginRegion: string(LoginRegionDefault)})
|
||||
provider.applyLoginRegionToToken(nil)
|
||||
token := &TokenData{}
|
||||
provider.applyLoginRegionToToken(token)
|
||||
if token.LoginRegion != string(LoginRegionInternational) {
|
||||
t.Fatalf("token login region = %q, want international", token.LoginRegion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMCPBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
|
||||
restore := PushMCPBaseURLOverride("https://pre-mcp.dingtalk.io/")
|
||||
defer restore()
|
||||
|
||||
if got := MCPBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-mcp.dingtalk.io" {
|
||||
t.Fatalf("international MCP base URL = %q, want override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoginBaseURLOverrideAffectsInternationalRegion(t *testing.T) {
|
||||
restore := PushLoginBaseURLOverride("https://pre-login.dingtalk.io/")
|
||||
defer restore()
|
||||
|
||||
if got := DeviceBaseURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io" {
|
||||
t.Fatalf("international device base URL = %q, want override", got)
|
||||
}
|
||||
if got := AuthorizeURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/oauth2/auth" {
|
||||
t.Fatalf("international authorize URL = %q, want override", got)
|
||||
}
|
||||
if got := UserAccessTokenURLForLoginRegion(LoginRegionInternational); got != "https://pre-login.dingtalk.io/v1.0/oauth2/userAccessToken" {
|
||||
t.Fatalf("international user access token URL = %q, want override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
|
||||
if resp.AccessToken == "" {
|
||||
return nil
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -206,6 +208,105 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) {
|
||||
t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCheckCLIAuthEnabledTraceHeadersAndDebugLog(t *testing.T) {
|
||||
t.Setenv("DINGTALK_TRACE_ID", "trace-for-cli-auth-test")
|
||||
applyCLIAuthTraceHeaders(nil, "trace-for-cli-auth-test")
|
||||
applyCLIAuthTraceHeaders(httptest.NewRequest(http.MethodGet, "https://example.com", nil), "")
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer slog.SetDefault(previousLogger)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("EagleEye-TraceId"); got != "trace-for-cli-auth-test" {
|
||||
t.Errorf("EagleEye-TraceId = %q, want trace-for-cli-auth-test", got)
|
||||
}
|
||||
if got := r.Header.Get("X-Dingtalk-Trace-Id"); got != "trace-for-cli-auth-test" {
|
||||
t.Errorf("X-Dingtalk-Trace-Id = %q, want trace-for-cli-auth-test", got)
|
||||
}
|
||||
w.Header().Set("EagleEye-TraceId", "server-trace-001")
|
||||
w.Header().Set("EagleEye-RpcId", "rpc-001")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CLIAuthStatus{
|
||||
Success: true,
|
||||
Result: &CLIAuthResult{CLIAuthEnabled: true},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
p := &OAuthProvider{configDir: configDir, httpClient: srv.Client()}
|
||||
|
||||
status, err := p.CheckCLIAuthEnabled(context.Background(), "sensitive-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Result == nil || !status.Result.CLIAuthEnabled {
|
||||
t.Fatal("expected CLIAuthEnabled=true")
|
||||
}
|
||||
|
||||
logs := logBuf.String()
|
||||
for _, want := range []string{
|
||||
"auth.cli_auth_enabled.request",
|
||||
"auth.cli_auth_enabled.response",
|
||||
"trace-for-cli-auth-test",
|
||||
"server-trace-001",
|
||||
"rpc-001",
|
||||
} {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Fatalf("debug logs missing %q, got: %s", want, logs)
|
||||
}
|
||||
}
|
||||
if strings.Contains(logs, "sensitive-token") {
|
||||
t.Fatalf("debug logs leaked access token: %s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageInternationalLoginRegionRequestWrappers(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case ClientIDPath:
|
||||
_ = json.NewEncoder(w).Encode(ClientIDResponse{Success: true, Result: "international-client"})
|
||||
case SuperAdminPath:
|
||||
_ = json.NewEncoder(w).Encode(SuperAdminResponse{Success: true, Result: []SuperAdmin{{StaffID: "admin"}}})
|
||||
case SendCliAuthApplyPath:
|
||||
_ = json.NewEncoder(w).Encode(SendApplyResponse{Success: true, Result: true})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldClient := oauthHTTPClient
|
||||
oauthHTTPClient = server.Client()
|
||||
restoreBaseURL := PushMCPBaseURLOverride(server.URL)
|
||||
t.Cleanup(func() {
|
||||
restoreBaseURL()
|
||||
oauthHTTPClient = oldClient
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
for name, fetch := range map[string]func() (string, error){
|
||||
"device": func() (string, error) { return deviceFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
|
||||
"oauth": func() (string, error) { return oauthFetchClientIDForLoginRegion(ctx, LoginRegionInternational) },
|
||||
} {
|
||||
if got, err := fetch(); err != nil || got != "international-client" {
|
||||
t.Fatalf("%s client ID = %q, %v", name, got, err)
|
||||
}
|
||||
}
|
||||
if got, err := deviceGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("device admins = %#v, %v", got, err)
|
||||
}
|
||||
if got, err := oauthGetAdminsForLoginRegion(ctx, "token", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("OAuth admins = %#v, %v", got, err)
|
||||
}
|
||||
if got, err := oauthSendApplyForLoginRegion(ctx, "token", "admin", LoginRegionInternational); err != nil || !got.Success {
|
||||
t.Fatalf("OAuth apply = %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCLIAuthEnabled_Disabled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -1211,7 +1212,13 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
|
||||
if err := os.Symlink(filepath.Join(keyDir, "dek"), filepath.Join(keyDir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, value := range map[string]string{"app.json": "{}", profilesJSONFile: "{}", "mcp_url": "https://mcp.test", "terminal_url": "https://terminal.test"} {
|
||||
for name, value := range map[string]string{
|
||||
"app.json": "{}",
|
||||
profilesJSONFile: "{}",
|
||||
"mcp_url": "https://mcp.test",
|
||||
config.ManagedMCPURLRegionFileName: "https://mcp.test",
|
||||
"terminal_url": "https://terminal.test",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(configDir, name), []byte(value), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1234,6 +1241,9 @@ func TestCrossPlatformCoveragePortableAuthBundleCoverageEdges(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(importDir, "app.json")); err != nil {
|
||||
t.Fatal("config file was not imported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(importDir, config.ManagedMCPURLRegionFileName)); err != nil {
|
||||
t.Fatal("managed MCP region marker was not imported")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")); err != nil {
|
||||
t.Fatal("encrypted token was not imported")
|
||||
}
|
||||
|
||||
@@ -74,6 +74,20 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
func deviceFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
if region.IsInternational() {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
}
|
||||
return deviceFetchClientID(ctx)
|
||||
}
|
||||
|
||||
func deviceGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
}
|
||||
return deviceGetAdmins(ctx, accessToken)
|
||||
}
|
||||
|
||||
type DeviceFlowProvider struct {
|
||||
configDir string
|
||||
clientID string
|
||||
@@ -85,6 +99,7 @@ type DeviceFlowProvider struct {
|
||||
httpClient *http.Client
|
||||
NoBrowser bool
|
||||
IdentityEnricher func(context.Context, *TokenData) error
|
||||
LoginRegion LoginRegion
|
||||
}
|
||||
|
||||
func NewDeviceFlowProvider(configDir string, logger *slog.Logger) *DeviceFlowProvider {
|
||||
@@ -104,6 +119,12 @@ func (p *DeviceFlowProvider) SetBaseURL(baseURL string) {
|
||||
p.baseURL = strings.TrimRight(baseURL, "/")
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) SetLoginRegion(region LoginRegion) {
|
||||
p.LoginRegion = region
|
||||
p.baseURL = DeviceBaseURLForLoginRegion(region)
|
||||
p.terminalBaseURL = MCPBaseURLForLoginRegion(region)
|
||||
}
|
||||
|
||||
// SetTerminalBaseURL sets the terminal API base URL for device flow polling.
|
||||
func (p *DeviceFlowProvider) SetTerminalBaseURL(baseURL string) {
|
||||
p.terminalBaseURL = strings.TrimRight(baseURL, "/")
|
||||
@@ -213,7 +234,7 @@ func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := deviceFetchClientID(ctx)
|
||||
mcpClientID, mcpErr := deviceFetchClientIDForLoginRegion(ctx, p.LoginRegion)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
@@ -272,6 +293,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
clientID: p.clientID,
|
||||
logger: p.logger,
|
||||
IdentityEnricher: p.IdentityEnricher,
|
||||
LoginRegion: p.LoginRegion,
|
||||
}
|
||||
tokenData, err := deviceExchangeCode(oauthProvider, ctx, tokenResult.AuthCode)
|
||||
if err != nil {
|
||||
@@ -331,7 +353,7 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token
|
||||
_, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。"))
|
||||
_, _ = fmt.Fprintln(p.output(), "")
|
||||
|
||||
admins, adminErr := deviceGetAdmins(ctx, tokenData.AccessToken)
|
||||
admins, adminErr := deviceGetAdminsForLoginRegion(ctx, tokenData.AccessToken, p.LoginRegion)
|
||||
if adminErr == nil && admins.Success && len(admins.Result) > 0 {
|
||||
maxAdmins := 3
|
||||
if len(admins.Result) < maxAdmins {
|
||||
|
||||
@@ -90,6 +90,17 @@ func TestRequestDeviceCodeSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDeviceFlowSetLoginRegionUsesInternationalBaseURL(t *testing.T) {
|
||||
provider := NewDeviceFlowProvider(t.TempDir(), newDeviceFlowTestLogger())
|
||||
provider.SetLoginRegion(LoginRegionInternational)
|
||||
if provider.baseURL != InternationalDeviceBaseURL {
|
||||
t.Fatalf("baseURL = %q, want %q", provider.baseURL, InternationalDeviceBaseURL)
|
||||
}
|
||||
if provider.terminalBaseURL != InternationalMCPBaseURL {
|
||||
t.Fatalf("terminalBaseURL = %q, want %q", provider.terminalBaseURL, InternationalMCPBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAuthorizationSucceedsAfterPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+112
-5
@@ -50,9 +50,15 @@ const (
|
||||
// AuthorizeURL is the DingTalk OAuth authorization page.
|
||||
AuthorizeURL = "https://login.dingtalk.com/oauth2/auth"
|
||||
|
||||
// InternationalAuthorizeURL is the DingTalk international OAuth authorization page.
|
||||
InternationalAuthorizeURL = "https://login.dingtalk.io/oauth2/auth"
|
||||
|
||||
// UserAccessTokenURL exchanges an authorization code for user tokens.
|
||||
UserAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"
|
||||
|
||||
// InternationalUserAccessTokenURL exchanges authorization codes for international user tokens.
|
||||
InternationalUserAccessTokenURL = "https://api.dingtalk.io/v1.0/oauth2/userAccessToken"
|
||||
|
||||
// UserInfoURL fetches the authenticated user's profile.
|
||||
UserInfoURL = "https://api.dingtalk.com/v1.0/contact/users/me"
|
||||
|
||||
@@ -75,6 +81,9 @@ const (
|
||||
// DefaultDeviceBaseURL is the login server base URL for device flow.
|
||||
DefaultDeviceBaseURL = "https://login.dingtalk.com"
|
||||
|
||||
// InternationalDeviceBaseURL is the international login server base URL for device flow.
|
||||
InternationalDeviceBaseURL = "https://login.dingtalk.io"
|
||||
|
||||
// DeviceCodePath requests a device_code and user_code.
|
||||
DeviceCodePath = "/oauth2/device/code.json"
|
||||
|
||||
@@ -96,11 +105,12 @@ const (
|
||||
LogoutContinueURL = "https://login.dingtalk.com"
|
||||
|
||||
// MCP API endpoints for CLI authorization management.
|
||||
DefaultMCPBaseURL = config.DefaultMCPBaseURL
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
ClientIDPath = "/cli/clientId"
|
||||
DefaultMCPBaseURL = config.DefaultMCPBaseURL
|
||||
InternationalMCPBaseURL = "https://mcp.dingtalk.io"
|
||||
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
|
||||
SuperAdminPath = "/cli/superAdmin"
|
||||
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
|
||||
ClientIDPath = "/cli/clientId"
|
||||
|
||||
// MCP OAuth endpoints (used when clientId is fetched from MCP).
|
||||
MCPOAuthTokenPath = "/oauth2/getToken"
|
||||
@@ -114,6 +124,57 @@ const (
|
||||
AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||
)
|
||||
|
||||
type LoginRegion string
|
||||
|
||||
const (
|
||||
LoginRegionDefault LoginRegion = ""
|
||||
LoginRegionInternational LoginRegion = "international"
|
||||
)
|
||||
|
||||
func (r LoginRegion) IsInternational() bool {
|
||||
return r == LoginRegionInternational
|
||||
}
|
||||
|
||||
func AuthorizeURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override + "/oauth2/auth"
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalAuthorizeURL
|
||||
}
|
||||
return AuthorizeURL
|
||||
}
|
||||
|
||||
func DeviceBaseURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalDeviceBaseURL
|
||||
}
|
||||
return DefaultDeviceBaseURL
|
||||
}
|
||||
|
||||
func MCPBaseURLForLoginRegion(region LoginRegion) string {
|
||||
if override := MCPBaseURLOverride(); override != "" {
|
||||
return override
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalMCPBaseURL
|
||||
}
|
||||
return GetMCPBaseURL()
|
||||
}
|
||||
|
||||
func UserAccessTokenURLForLoginRegion(region LoginRegion) string {
|
||||
if override := LoginBaseURLOverride(); override != "" {
|
||||
return override + "/v1.0/oauth2/userAccessToken"
|
||||
}
|
||||
if region.IsInternational() {
|
||||
return InternationalUserAccessTokenURL
|
||||
}
|
||||
return UserAccessTokenURL
|
||||
}
|
||||
|
||||
// GetTerminalBaseURL returns the terminal base URL with priority:
|
||||
// 1. ~/.dws/terminal_url file content (for pre-release environment)
|
||||
// 2. Default value (https://open-dev.dingtalk.com)
|
||||
@@ -146,8 +207,54 @@ var (
|
||||
// clientIDFromMCP indicates whether the clientID was fetched from MCP server.
|
||||
// When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
|
||||
clientIDFromMCP bool
|
||||
|
||||
loginBaseURLMu sync.RWMutex
|
||||
loginBaseURLOverride string
|
||||
mcpBaseURLMu sync.RWMutex
|
||||
mcpBaseURLOverride string
|
||||
)
|
||||
|
||||
// PushLoginBaseURLOverride sets a process-local DingTalk login base URL
|
||||
// override and returns a restore function.
|
||||
func PushLoginBaseURLOverride(baseURL string) func() {
|
||||
loginBaseURLMu.Lock()
|
||||
previous := loginBaseURLOverride
|
||||
loginBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
loginBaseURLMu.Unlock()
|
||||
return func() {
|
||||
loginBaseURLMu.Lock()
|
||||
loginBaseURLOverride = previous
|
||||
loginBaseURLMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func LoginBaseURLOverride() string {
|
||||
loginBaseURLMu.RLock()
|
||||
defer loginBaseURLMu.RUnlock()
|
||||
return loginBaseURLOverride
|
||||
}
|
||||
|
||||
// PushMCPBaseURLOverride sets a process-local MCP base URL override and returns
|
||||
// a restore function. It is intended for one command invocation, such as
|
||||
// pre-release smoke testing.
|
||||
func PushMCPBaseURLOverride(baseURL string) func() {
|
||||
mcpBaseURLMu.Lock()
|
||||
previous := mcpBaseURLOverride
|
||||
mcpBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
mcpBaseURLMu.Unlock()
|
||||
return func() {
|
||||
mcpBaseURLMu.Lock()
|
||||
mcpBaseURLOverride = previous
|
||||
mcpBaseURLMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func MCPBaseURLOverride() string {
|
||||
mcpBaseURLMu.RLock()
|
||||
defer mcpBaseURLMu.RUnlock()
|
||||
return mcpBaseURLOverride
|
||||
}
|
||||
|
||||
// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
|
||||
func SetClientIDFromMCP(id string) {
|
||||
clientMu.Lock()
|
||||
|
||||
+132
-15
@@ -20,6 +20,7 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -51,7 +53,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
"code": code,
|
||||
"grantType": "authorization_code",
|
||||
}
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURL, body)
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
|
||||
// Snapshot credentials used for this token (for refresh)
|
||||
data.ClientID = clientID
|
||||
data.Source = resolveCredentialSource()
|
||||
p.applyLoginRegionToToken(data)
|
||||
// Save clientSecret for future refresh (even if env changes)
|
||||
if err := oauthSaveClientSecret(clientID, clientSecret); err != nil {
|
||||
// Log warning but don't fail login
|
||||
@@ -91,11 +94,36 @@ func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenDa
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) loginRegion() LoginRegion {
|
||||
if p == nil {
|
||||
return LoginRegionDefault
|
||||
}
|
||||
return p.LoginRegion
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) useTokenLoginRegion(data *TokenData) {
|
||||
if p == nil || p.LoginRegion != LoginRegionDefault || data == nil {
|
||||
return
|
||||
}
|
||||
if region := LoginRegion(strings.TrimSpace(data.LoginRegion)); region != LoginRegionDefault {
|
||||
p.LoginRegion = region
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) applyLoginRegionToToken(data *TokenData) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
if region := p.loginRegion(); region != LoginRegionDefault {
|
||||
data.LoginRegion = string(region)
|
||||
}
|
||||
}
|
||||
|
||||
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
|
||||
// This is used when client secret is not available (server-side secret management).
|
||||
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
|
||||
clientID := ClientID()
|
||||
url := GetMCPBaseURL() + MCPOAuthTokenPath
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPOAuthTokenPath
|
||||
body := map[string]string{
|
||||
"clientId": clientID,
|
||||
"authCode": code,
|
||||
@@ -112,6 +140,7 @@ func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*T
|
||||
// Snapshot credentials used for this token (for refresh)
|
||||
data.ClientID = clientID
|
||||
data.Source = "mcp"
|
||||
p.applyLoginRegionToToken(data)
|
||||
// MCP mode doesn't need to save clientSecret (server-side managed)
|
||||
return data, nil
|
||||
}
|
||||
@@ -120,8 +149,10 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
// Use stored Source to determine refresh path (not current runtime state)
|
||||
// This ensures refresh works even if environment variables changed since login
|
||||
if data.Source == "mcp" {
|
||||
p.useTokenLoginRegion(data)
|
||||
return p.refreshViaMCP(ctx, data)
|
||||
}
|
||||
p.useTokenLoginRegion(data)
|
||||
|
||||
// Direct mode: use stored clientId and load saved clientSecret
|
||||
clientID := data.ClientID
|
||||
@@ -145,7 +176,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
"refreshToken": data.RefreshToken,
|
||||
"grantType": "refresh_token",
|
||||
}
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURL, body)
|
||||
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -156,6 +187,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
// Preserve original credentials info
|
||||
updated.ClientID = data.ClientID
|
||||
updated.Source = data.Source
|
||||
updated.LoginRegion = data.LoginRegion
|
||||
updated.PersistentCode = data.PersistentCode
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
@@ -185,7 +217,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录")
|
||||
}
|
||||
|
||||
url := GetMCPBaseURL() + MCPRefreshTokenPath
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPRefreshTokenPath
|
||||
body := map[string]string{
|
||||
"clientId": clientID,
|
||||
"refreshToken": data.RefreshToken,
|
||||
@@ -202,6 +234,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
// Preserve original credentials info
|
||||
updated.ClientID = data.ClientID
|
||||
updated.Source = data.Source
|
||||
updated.LoginRegion = data.LoginRegion
|
||||
updated.PersistentCode = data.PersistentCode
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
@@ -371,6 +404,10 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
return buildAuthURLForRegion(clientID, redirectURI, targetCorpID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func buildAuthURLForRegion(clientID, redirectURI, targetCorpID string, region LoginRegion) string {
|
||||
params := url.Values{
|
||||
"client_id": {clientID},
|
||||
"redirect_uri": {redirectURI},
|
||||
@@ -381,7 +418,7 @@ func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
||||
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
|
||||
params.Set("corpId", targetCorpID)
|
||||
}
|
||||
return AuthorizeURL + "?" + params.Encode()
|
||||
return AuthorizeURLForLoginRegion(region) + "?" + params.Encode()
|
||||
}
|
||||
|
||||
const successHTML = `<!doctype html>
|
||||
@@ -937,14 +974,15 @@ const notEnabledHTML = `<!doctype html>
|
||||
clientId = status.clientId || "";
|
||||
applySent = status.applySent || false;
|
||||
selectedAdminId = status.selectedAdminId || "";
|
||||
const authorizeUrl = status.authorizeUrl || "";
|
||||
|
||||
if (clientId) {
|
||||
if (clientId && authorizeUrl) {
|
||||
const port = location.port;
|
||||
const redirectUri = encodeURIComponent(
|
||||
"http://127.0.0.1:" + port + "/callback"
|
||||
);
|
||||
backLink.href =
|
||||
"https://login.dingtalk.com/oauth2/auth?client_id=" +
|
||||
authorizeUrl + "?client_id=" +
|
||||
clientId +
|
||||
"&prompt=consent&redirect_uri=" +
|
||||
redirectUri +
|
||||
@@ -1398,6 +1436,7 @@ const mcpRequestMaxRetries = 3
|
||||
// false negatives caused by momentary network issues.
|
||||
func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
||||
var lastErr error
|
||||
traceID := cliAuthTraceID()
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
@@ -1406,7 +1445,7 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
status, err := p.doCheckCLIAuthEnabled(ctx, accessToken)
|
||||
status, err := p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, attempt+1, traceID)
|
||||
if err == nil {
|
||||
return status, nil
|
||||
}
|
||||
@@ -1416,16 +1455,27 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
||||
url := GetMCPBaseURL() + CLIAuthEnabledPath
|
||||
return p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, 1, cliAuthTraceID())
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) doCheckCLIAuthEnabledAttempt(ctx context.Context, accessToken string, attempt int, traceID string) (*CLIAuthStatus, error) {
|
||||
url := MCPBaseURLForLoginRegion(p.loginRegion()) + CLIAuthEnabledPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("x-user-access-token", accessToken)
|
||||
applyCLIAuthTraceHeaders(req, traceID)
|
||||
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
|
||||
req.Header.Set("x-dws-channel", ch)
|
||||
}
|
||||
applyEditionEnterpriseCredentialHeaders(req)
|
||||
slog.Debug("auth.cli_auth_enabled.request",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"trace_id", traceID,
|
||||
"channel", os.Getenv("DWS_CHANNEL"),
|
||||
)
|
||||
|
||||
client := p.httpClient
|
||||
if client == nil {
|
||||
@@ -1433,9 +1483,23 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("auth.cli_auth_enabled.error",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"trace_id", traceID,
|
||||
"error", err,
|
||||
)
|
||||
return nil, fmt.Errorf("sending request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
slog.Debug("auth.cli_auth_enabled.response",
|
||||
"attempt", attempt,
|
||||
"url", url,
|
||||
"status", resp.StatusCode,
|
||||
"trace_id", traceID,
|
||||
"response_trace_id", cliAuthResponseTraceID(resp.Header),
|
||||
"eagleeye_rpc_id", resp.Header.Get("EagleEye-RpcId"),
|
||||
)
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
if err != nil {
|
||||
@@ -1449,9 +1513,42 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
func cliAuthTraceID() string {
|
||||
if traceID := strings.TrimSpace(os.Getenv("DINGTALK_TRACE_ID")); traceID != "" {
|
||||
return traceID
|
||||
}
|
||||
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
|
||||
func applyCLIAuthTraceHeaders(req *http.Request, traceID string) {
|
||||
if req == nil || traceID == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set("EagleEye-TraceId", traceID)
|
||||
req.Header.Set("X-Dingtalk-Trace-Id", traceID)
|
||||
}
|
||||
|
||||
func cliAuthResponseTraceID(headers http.Header) string {
|
||||
for _, key := range []string{
|
||||
"EagleEye-TraceId",
|
||||
"X-Trace-Id",
|
||||
"X-Request-Id",
|
||||
"X-Dingtalk-Trace-Id",
|
||||
} {
|
||||
if value := strings.TrimSpace(headers.Get(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSuperAdmins fetches the list of corp super admins.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func GetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1461,7 +1558,7 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
result, err := doGetSuperAdmins(ctx, accessToken)
|
||||
result, err := doGetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -1471,7 +1568,11 @@ func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespons
|
||||
}
|
||||
|
||||
func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
||||
url := GetMCPBaseURL() + SuperAdminPath
|
||||
return doGetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doGetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + SuperAdminPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
@@ -1500,6 +1601,10 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo
|
||||
// SendCliAuthApply sends a CLI auth apply request to the specified admin.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
||||
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func SendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1509,7 +1614,7 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID)
|
||||
result, err := doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
@@ -1519,7 +1624,11 @@ func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*S
|
||||
}
|
||||
|
||||
func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
||||
url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
|
||||
return doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doSendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
@@ -1557,6 +1666,10 @@ type ClientIDResponse struct {
|
||||
// This is used when no client ID is provided via flags, config, or env vars.
|
||||
// It retries up to mcpRequestMaxRetries times on transient errors.
|
||||
func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func FetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
@@ -1566,7 +1679,7 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
id, err := doFetchClientIDFromMCP(ctx)
|
||||
id, err := doFetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
@@ -1576,7 +1689,11 @@ func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
func doFetchClientIDFromMCP(ctx context.Context) (string, error) {
|
||||
url := GetMCPBaseURL() + ClientIDPath
|
||||
return doFetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
|
||||
}
|
||||
|
||||
func doFetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
url := MCPBaseURLForLoginRegion(region) + ClientIDPath
|
||||
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
|
||||
@@ -68,6 +68,27 @@ var (
|
||||
oauthSleep = time.Sleep
|
||||
)
|
||||
|
||||
func oauthFetchClientIDForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
||||
if region.IsInternational() {
|
||||
return FetchClientIDFromMCPForLoginRegion(ctx, region)
|
||||
}
|
||||
return oauthFetchClientID(ctx)
|
||||
}
|
||||
|
||||
func oauthGetAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return GetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
||||
}
|
||||
return oauthGetAdmins(ctx, accessToken)
|
||||
}
|
||||
|
||||
func oauthSendApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
||||
if region.IsInternational() {
|
||||
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
|
||||
}
|
||||
return oauthSendApply(ctx, accessToken, adminStaffID)
|
||||
}
|
||||
|
||||
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
|
||||
type OAuthProvider struct {
|
||||
configDir string
|
||||
@@ -80,6 +101,7 @@ type OAuthProvider struct {
|
||||
// IdentityEnricher resolves userId/userName/corpName while the freshly
|
||||
// exchanged access token is still only in memory.
|
||||
IdentityEnricher func(context.Context, *TokenData) error
|
||||
LoginRegion LoginRegion
|
||||
}
|
||||
|
||||
// NewOAuthProvider creates a new OAuth provider.
|
||||
@@ -173,7 +195,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := oauthFetchClientID(ctx)
|
||||
mcpClientID, mcpErr := oauthFetchClientIDForLoginRegion(ctx, p.LoginRegion)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
@@ -401,7 +423,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
|
||||
return
|
||||
}
|
||||
result, err := oauthGetAdmins(ctx, token.AccessToken)
|
||||
result, err := oauthGetAdminsForLoginRegion(ctx, token.AccessToken, p.LoginRegion)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
|
||||
return
|
||||
@@ -425,7 +447,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`))
|
||||
return
|
||||
}
|
||||
result, err := oauthSendApply(ctx, token.AccessToken, adminStaffID)
|
||||
result, err := oauthSendApplyForLoginRegion(ctx, token.AccessToken, adminStaffID, p.LoginRegion)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error())
|
||||
return
|
||||
@@ -448,7 +470,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
applySent := callbackApplySent
|
||||
selectedAdminId := callbackSelectedAdminId
|
||||
callbackTokenMu.Unlock()
|
||||
_, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId)
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"clientId": p.clientID,
|
||||
"authorizeUrl": AuthorizeURLForLoginRegion(p.LoginRegion),
|
||||
"applySent": applySent,
|
||||
"selectedAdminId": selectedAdminId,
|
||||
})
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
|
||||
// API endpoint: check CLI auth enabled status
|
||||
@@ -491,7 +519,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
_ = server.Shutdown(shutCtx)
|
||||
}()
|
||||
|
||||
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
|
||||
authURL := buildAuthURLForRegion(p.clientID, redirectURI, p.TargetCorpID, p.LoginRegion)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("authorization URL", "url", authURL)
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
|
||||
|
||||
func portableConfigFiles(configDir string) ([]string, error) {
|
||||
var files []string
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
|
||||
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", config.ManagedMCPURLRegionFileName, "terminal_url"}
|
||||
for _, pattern := range patterns {
|
||||
matches, err := portableGlob(filepath.Join(configDir, pattern))
|
||||
if err != nil {
|
||||
|
||||
@@ -97,6 +97,7 @@ type TokenData struct {
|
||||
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LoginRegion string `json:"login_region,omitempty"`
|
||||
// LegacyOrgScopedProfile is an in-memory destination for an explicitly
|
||||
// matched historical profile whose userId was never resolved. It is never
|
||||
// persisted as token material.
|
||||
|
||||
@@ -177,6 +177,7 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
aliasMap := make(map[string]string)
|
||||
blockedSet := make(map[string]bool)
|
||||
excludedSet := make(map[string]bool)
|
||||
claimedRealSet := make(map[string]bool)
|
||||
pendingReview := ov.Confirm || ov.Investigate
|
||||
|
||||
for boundFlag, conceptID := range ov.Bind {
|
||||
@@ -218,6 +219,14 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
for m := range eff {
|
||||
if _, isReal := realByMorph[m]; isReal {
|
||||
claimedRealSet[m] = true
|
||||
}
|
||||
}
|
||||
for _, exclude := range concept.Excludes {
|
||||
excludedSet[cmdutil.Morph(exclude)] = true
|
||||
}
|
||||
visible := distinctRealNames(candidates, true)
|
||||
var canon string
|
||||
switch len(visible) {
|
||||
@@ -267,21 +276,18 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
}
|
||||
aliasMap[m] = canon
|
||||
}
|
||||
// Excludes are not passive prose: once this concept is active on a
|
||||
// reviewed command, a non-real excluded spelling is protected from
|
||||
// downstream fuzzy correction. A real flag is left alone because it
|
||||
// already has an independently valid command-local meaning.
|
||||
for _, exclude := range concept.Excludes {
|
||||
morphed := cmdutil.Morph(exclude)
|
||||
if _, isReal := realByMorph[morphed]; !isReal {
|
||||
excludedSet[morphed] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for excluded := range excludedSet {
|
||||
if _, isAlias := aliasMap[excluded]; !isAlias {
|
||||
blockedSet[excluded] = true
|
||||
if _, isAlias := aliasMap[excluded]; isAlias {
|
||||
continue
|
||||
}
|
||||
if claimedRealSet[excluded] {
|
||||
continue
|
||||
}
|
||||
if _, isReal := realByMorph[excluded]; isReal {
|
||||
continue
|
||||
}
|
||||
blockedSet[excluded] = true
|
||||
}
|
||||
|
||||
// (b) Command scoped aliases override concept reductions.
|
||||
|
||||
@@ -889,13 +889,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"cursor"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category add-conv",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
CLIPath: "chat category add-conv",
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category create",
|
||||
@@ -920,13 +916,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"category-ids"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category remove-conv",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
CLIPath: "chat category remove-conv",
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category rename",
|
||||
@@ -970,23 +962,19 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat conversation-info",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group audit-join-validation",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"},
|
||||
},
|
||||
{
|
||||
@@ -1006,12 +994,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group dismiss",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group get-by-group-id",
|
||||
@@ -1020,12 +1005,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group invite-url",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group members",
|
||||
@@ -1100,52 +1082,37 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group notice create",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group notice edit",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group notice get",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group notice list",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group quit",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group rename",
|
||||
@@ -1161,23 +1128,17 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group set-admin",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"user-ids": "users",
|
||||
"chat-id": "conversation-id",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group set-history",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group share-invite",
|
||||
@@ -1191,151 +1152,111 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group transfer-owner",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-alias",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-icon",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-nick",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-settings",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-mute",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-icon",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-nick",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-settings",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-mute",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-mute-member",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"user-ids": "users",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role add",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role list",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role query-user",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role remove",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role remove-user",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role set-user",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role update",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat hide",
|
||||
@@ -1369,12 +1290,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message add-emoji",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message add-favorite",
|
||||
@@ -1391,12 +1310,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message add-text-emotion",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message combine-forward",
|
||||
@@ -1427,8 +1344,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Aliases: map[string]string{
|
||||
"dest-open-cid": "dest-conversation-id",
|
||||
"destination-conversation-id": "dest-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
"source-conversation-id": "src-conversation-id",
|
||||
"src-open-cid": "src-conversation-id",
|
||||
"target-conversation-id": "dest-conversation-id",
|
||||
@@ -1453,21 +1368,20 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message list",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"open-conversation-id": "group",
|
||||
"page-size": "limit",
|
||||
"per-page": "limit",
|
||||
"staff-id": "user",
|
||||
"start": "time",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"page-size": "limit",
|
||||
"per-page": "limit",
|
||||
"staff-id": "user",
|
||||
"start": "time",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message list-all",
|
||||
@@ -1514,12 +1428,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message list-mentions",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
CLIPath: "chat message list-mentions",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message list-pin-msg",
|
||||
@@ -1534,8 +1445,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message list-topic-replies",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
@@ -1549,11 +1459,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message read-status",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"msg-id": "message-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "message-id",
|
||||
"user-ids": "users",
|
||||
"chat-id": "conversation-id",
|
||||
"msg-id": "message-id",
|
||||
"open-message-id": "message-id",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "topic-id", "uid", "userid"},
|
||||
},
|
||||
@@ -1561,30 +1470,25 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
CLIPath: "chat message recall",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message recall-by-bot",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"robot": "robot-code",
|
||||
"robot": "robot-code",
|
||||
},
|
||||
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message remove-emoji",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message remove-favorite",
|
||||
@@ -1601,12 +1505,10 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message remove-text-emotion",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message reply",
|
||||
@@ -1620,12 +1522,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-id", "msg-id", "msg-ids", "name", "open-conversation-ids", "open-message-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message search",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
CLIPath: "chat message search",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message search-advanced",
|
||||
@@ -1638,28 +1537,25 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message send",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"file": "file-path",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"to-user": "user",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
"file": "file-path",
|
||||
"staff-id": "user",
|
||||
"to-user": "user",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-by-bot",
|
||||
Aliases: map[string]string{
|
||||
"at-users": "at-user-ids",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"robot": "robot-code",
|
||||
"user-ids": "users",
|
||||
"at-users": "at-user-ids",
|
||||
"robot": "robot-code",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "staff-id", "target", "to-user-id", "uid", "user", "user-id", "userid"},
|
||||
Ambiguous: []string{"at-ids"},
|
||||
Ambiguous: []string{"at-ids", "chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-by-webhook",
|
||||
@@ -1668,14 +1564,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-card",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
|
||||
CLIPath: "chat message send-card",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
|
||||
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message set-pin-msg",
|
||||
@@ -1684,8 +1575,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
@@ -1696,8 +1585,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
@@ -1708,8 +1595,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
@@ -1720,17 +1605,13 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat mute",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"group": "conversation-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"chat-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "open-conversation-ids"},
|
||||
},
|
||||
@@ -2548,7 +2429,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"node-id": "node",
|
||||
"url": "node",
|
||||
},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "offset", "page", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -237,6 +237,42 @@ func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceLeafParamAliasesBindExcludesRealFlags(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases(
|
||||
"demo cmd",
|
||||
realMap(realFlag{name: "id"}, realFlag{name: "name"}, realFlag{name: "query"}),
|
||||
[]Concept{
|
||||
{ID: "base_id", Members: []string{"base-id", "base-token"}, Excludes: []string{"keyword", "name", "query", "unsafe"}},
|
||||
{ID: "query", Members: []string{"query", "keyword"}},
|
||||
},
|
||||
CommandOverride{Bind: map[string]string{"id": "base_id"}},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("reduceLeafParamAliases() problems = %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("reduceLeafParamAliases() entry = nil")
|
||||
}
|
||||
if entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
|
||||
t.Fatalf("bound aliases = %#v, want base-id/base-token -> id", entry.Aliases)
|
||||
}
|
||||
if entry.Aliases["keyword"] != "query" {
|
||||
t.Fatalf("query alias = %#v, want keyword -> query", entry.Aliases)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "keyword") {
|
||||
t.Fatalf("excluded alias entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "name") {
|
||||
t.Fatalf("real excluded flag entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "query") {
|
||||
t.Fatalf("claimed real excluded flag entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if !containsParamAlias(entry.Blocked, "unsafe") {
|
||||
t.Fatalf("non-real excluded flag was not blocked: %#v", entry.Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasEntryLookupMethods(t *testing.T) {
|
||||
entry := ParamAliasEntry{
|
||||
Aliases: map[string]string{"uid": "user"},
|
||||
@@ -457,6 +493,22 @@ func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcep
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesExcludesDoNotBlockRealFlag(t *testing.T) {
|
||||
concepts := []Concept{
|
||||
{ID: "single_id", Members: []string{"id", "item-id"}, Excludes: []string{"item-ids"}},
|
||||
}
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}, realFlag{name: "item-ids"}), concepts, CommandOverride{})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("expected a reduced entry")
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "item-ids") {
|
||||
t.Fatalf("real exclude was blocked: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
|
||||
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
|
||||
for name, override := range map[string]CommandOverride{
|
||||
@@ -472,6 +524,58 @@ func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedParamAliasesBlockPluralListSpellingsOnSingleIDCommands(t *testing.T) {
|
||||
entries := make(map[string]ParamAliasEntry, len(generatedParamAliases))
|
||||
for _, entry := range generatedParamAliases {
|
||||
entries[entry.CLIPath] = entry
|
||||
}
|
||||
assertBlocked := func(path string, names ...string) {
|
||||
t.Helper()
|
||||
entry, ok := entries[path]
|
||||
if !ok {
|
||||
t.Fatalf("missing generated alias entry for %q", path)
|
||||
}
|
||||
for _, name := range names {
|
||||
if !entry.IsBlocked(cmdutil.Morph(name)) {
|
||||
t.Fatalf("%s: %q not blocked; entry = %#v", path, name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"chat message add-emoji",
|
||||
"chat message remove-emoji",
|
||||
"chat message add-text-emotion",
|
||||
"chat message remove-text-emotion",
|
||||
} {
|
||||
assertBlocked(path, "msg-ids", "message-ids")
|
||||
}
|
||||
for _, path := range []string{
|
||||
"chat message send",
|
||||
"chat conversation-info",
|
||||
"chat category add-conv",
|
||||
"chat category remove-conv",
|
||||
"chat message list",
|
||||
"chat message list-mentions",
|
||||
"chat message recall-by-bot",
|
||||
"chat message search",
|
||||
} {
|
||||
assertBlocked(path, "conversation-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedParamAliasesKeepAuditJoinUserRoleAmbiguous(t *testing.T) {
|
||||
entry, ok := LookupParamAlias("chat group audit-join-validation")
|
||||
if !ok {
|
||||
t.Fatal("missing generated alias entry for chat group audit-join-validation")
|
||||
}
|
||||
for _, name := range []string{"user", "user-id", "userid", "uid", "staff-id"} {
|
||||
if !entry.IsAmbiguous(cmdutil.Morph(name)) {
|
||||
t.Fatalf("%q not ambiguous; entry = %#v", name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
|
||||
// at the Go level, complementing the byte-identity drift gate.
|
||||
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
|
||||
|
||||
@@ -60,11 +60,17 @@
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
"chat group members remove": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --chat/--group/--id/--conversation-id stay native; numeric groupId and list spellings are rejected."},
|
||||
"chat mute": {"scoped_aliases": {"group": "conversation-id", "chat-id": "conversation-id", "open-conversation-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id/--id/--chat remain unchanged; other reviewed openConversationId spellings reduce to --conversation-id."},
|
||||
"chat conversation-info": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat group-mute": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat group-mute-member": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message list-mentions": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message recall-by-bot": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message search": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat mute": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id and hidden compatibility --group/--id/--chat/--open-conversation-id remain unchanged; reviewed chat-id reduces to --conversation-id."},
|
||||
"drive list": {"ambiguous": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]},
|
||||
"drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true},
|
||||
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
|
||||
@@ -84,12 +90,12 @@
|
||||
"chat +unread-chats": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat message list-unread-conversations": {"scoped_aliases": {"limit": "count", "size": "count"}, "scope_strict": true, "note": "On this exact command, limit and size both denote the returned unread-conversation count."},
|
||||
"chat +messages-list-direct": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
|
||||
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented."},
|
||||
"chat message list": {"scoped_aliases": {"start": "time"}, "block": ["end"], "ambiguous": ["chat-id", "open-conversation-id"], "scope_strict": true, "note": "This exact command accepts one start boundary in yyyy-MM-dd HH:mm:ss; an end-only input cannot be represented. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message list-by-sender": {"scoped_aliases": {"user-id": "sender-user-id", "open-dingtalk-id": "sender-open-dingtalk-id"}, "block": ["time"], "scope_strict": true, "note": "Only same-role sender identifiers are mapped; --time cannot supply the required RFC3339 start/end range."},
|
||||
"contact +resolve-dept": {"bind": {"name": "search_query"}, "note": "The real --name is a department-name search keyword and carries the search_query concept on this shortcut."},
|
||||
"contact +list-sub-depts": {"block": ["name", "query"], "note": "--dept is an integer department id; names and search queries require a separate resolution command"},
|
||||
"contact +dept-members": {"bind": {"dept": "search_query"}, "scoped_aliases": {"name": "dept"}, "note": "The real --dept is a department-name search keyword; search spellings come from search_query, while --name remains command-scoped."},
|
||||
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported."},
|
||||
"chat message send": {"scoped_aliases": {"to-user": "user", "file": "file-path"}, "ambiguous": ["chat-id", "open-conversation-id"], "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +group-members": {"bind": {"group": "group_name"}, "note": "The real --group is a group-name search keyword on this shortcut, not an identifier."},
|
||||
"chat +category-create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact shortcut."},
|
||||
"chat category create": {"scoped_aliases": {"name": "title"}, "scope_strict": true, "note": "The reviewed name/title mapping preserves the category display-name value on this exact command."},
|
||||
@@ -98,8 +104,8 @@
|
||||
"chat +category-delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category delete": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category list-conversations": {"block": ["category-ids"], "note": "This command requires one category id; list cardinality is not reduced automatically."},
|
||||
"chat category add-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
|
||||
"chat category remove-conv": {"block": ["category-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input."},
|
||||
"chat category add-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat category remove-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +chat-role-update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat group-role remove": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
@@ -109,7 +115,7 @@
|
||||
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
|
||||
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids", "chat-id", "open-conversation-id"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
@@ -142,7 +148,7 @@
|
||||
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
|
||||
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
|
||||
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
|
||||
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
@@ -328,7 +334,7 @@
|
||||
{"command": "chat group rename", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat group rename", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
|
||||
{"command": "chat message send", "emitted": "conversation-id", "expect": "group", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped"},
|
||||
{"command": "chat message add-emoji", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat message add-emoji", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:group-id-vs-open-conversation-id"},
|
||||
{"command": "chat +group-members", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +send-to-group", "emitted": "group-name", "expect": "group", "via": "concept:group_name+bind"},
|
||||
@@ -466,6 +472,7 @@
|
||||
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
|
||||
{"command": "chat group audit-join-validation", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
|
||||
@@ -1354,16 +1354,16 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
interfaceType string
|
||||
}{
|
||||
{"chat message edit", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat message edit", "msg-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "message-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "message-id", "openMsgId", true, ""},
|
||||
{"chat message send", "idempotency-key", "uuid", false, ""},
|
||||
{"chat message send-card", "at-all", "atAll", false, ""},
|
||||
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
|
||||
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
|
||||
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
|
||||
{"chat category list-by-conv", "group", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "group", "openConversationId", true, ""},
|
||||
{"chat category list-by-conv", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "conversation-id", "", true, ""},
|
||||
{"chat group upgrade-to-external", "extension", "extension", false, "object"},
|
||||
{"chat +messages-send-card", "receiver-open-dingtalk-id", "receiverOpenDingTalkId", false, ""},
|
||||
{"chat message list-favorites", "size", "", false, "string"},
|
||||
@@ -1397,7 +1397,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden conversation aliases must stay unpublished (merge-base parity).
|
||||
// Manifest-covered migrations hide legacy aliases; manifest-external
|
||||
// commands keep their existing visible flags for compatibility.
|
||||
editLeaf, err := queryDeliverySchemaPayload([]string{"chat message edit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1421,9 +1422,52 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listParams := schemaMap(listByConv["parameters"])
|
||||
for _, hidden := range []string{"conversation-id", "id"} {
|
||||
if _, ok := listParams[hidden]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden)
|
||||
if _, ok := listParams["conversation-id"]; !ok {
|
||||
t.Fatalf("chat category list-by-conv missing public canonical --conversation-id")
|
||||
}
|
||||
if _, ok := listParams["group"]; !ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly hides manifest-external --group")
|
||||
}
|
||||
if _, ok := listParams["id"]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --id")
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"chat message add-emoji",
|
||||
"chat message remove-emoji",
|
||||
"chat message add-text-emotion",
|
||||
"chat message remove-text-emotion",
|
||||
} {
|
||||
leaf, err := queryDeliverySchemaPayload([]string{path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := schemaMap(leaf["parameters"])
|
||||
if _, ok := params["conversation-id"]; !ok {
|
||||
t.Fatalf("%s missing public canonical --conversation-id", path)
|
||||
}
|
||||
for _, visible := range []string{"group", "id", "chat"} {
|
||||
if _, ok := params[visible]; !ok {
|
||||
t.Fatalf("%s unexpectedly hides manifest-external --%s", path, visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groupBots, err := queryDeliverySchemaPayload([]string{"chat group bots"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groupBotsParams := schemaMap(groupBots["parameters"])
|
||||
group := groupBotsParams["group"]
|
||||
if group == nil {
|
||||
t.Fatal("chat group bots missing public legacy --group")
|
||||
}
|
||||
if group["property"] != "openConversationId" {
|
||||
t.Fatalf("chat group bots --group property = %#v, want openConversationId", group["property"])
|
||||
}
|
||||
for _, migrated := range []string{"conversation-id", "group-name"} {
|
||||
if _, ok := groupBotsParams[migrated]; ok {
|
||||
t.Fatalf("chat group bots unexpectedly publishes migrated --%s", migrated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,19 +43,14 @@ func init() {
|
||||
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
|
||||
})
|
||||
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
|
||||
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
|
||||
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
|
||||
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
|
||||
|
||||
@@ -252,7 +252,8 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
|
||||
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.get_group_mute_config --conversation-id": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.get_group_mute_config --group": "Reviewed legacy Schema compatibility: the historical visible --group wrapper input was required but did not publish a direct interface property.",
|
||||
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -265,6 +266,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.remove_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.remove_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
|
||||
|
||||
+25
-12
@@ -578,18 +578,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
for _, alias := range flag.Aliases {
|
||||
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
if registered := cmd.Flags().Lookup(alias); registered != nil {
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
flag.Name,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
AnnotateFlagAlias(cmd, alias, flag.Name)
|
||||
}
|
||||
if flag.MarkRequired {
|
||||
_ = cmd.MarkFlagRequired(flag.Name)
|
||||
@@ -600,6 +589,30 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
}
|
||||
}
|
||||
|
||||
// AnnotateFlagAlias records framework-owned evidence that aliasName is a hidden
|
||||
// compatibility alias for canonicalName. It is for commands that already own
|
||||
// their Cobra flag registration outside FlagSpec but still need the same
|
||||
// interface-snapshot alias contract as FlagSpec.Aliases.
|
||||
func AnnotateFlagAlias(cmd *cobra.Command, aliasName, canonicalName string) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
registered := cmd.Flags().Lookup(aliasName)
|
||||
if registered == nil {
|
||||
return
|
||||
}
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
canonicalName,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
|
||||
// RegisterFlag registers one flag by Kind. Default is applied at registration
|
||||
// for every kind so --help DefValue matches the declared fallback.
|
||||
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
|
||||
|
||||
@@ -116,6 +116,16 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAnnotateFlagAliasIgnoresMissingInputs(t *testing.T) {
|
||||
AnnotateFlagAlias(nil, "alias", "canonical")
|
||||
|
||||
cmd := newTestCommand()
|
||||
AnnotateFlagAlias(cmd, "missing", "canonical")
|
||||
if flag := cmd.Flags().Lookup("missing"); flag != nil {
|
||||
t.Fatalf("unexpected missing flag registered: %#v", flag)
|
||||
}
|
||||
}
|
||||
|
||||
// ── effective value fallback chain ─────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageEffectiveValueFallbackChain(t *testing.T) {
|
||||
|
||||
@@ -15,9 +15,13 @@ package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
|
||||
@@ -382,6 +386,27 @@ func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePrintJSONCLIOrgNotAuthorizedUsesInternationalActionURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://mcp.dingtalk.io\n"), config.FilePerm); err != nil {
|
||||
t.Fatalf("WriteFile(mcp_url) error = %v", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewAPI(
|
||||
"business error",
|
||||
WithServerDiag(ServerDiagnostics{ServerErrorCode: "CLI_ORG_NOT_AUTHORIZED"}),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
|
||||
want := `"action_url": "https://open-dev.dingtalk.io/fe/old#/developerSettings"`
|
||||
if got := b.String(); !strings.Contains(got, want) {
|
||||
t.Fatalf("expected international action_url %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+808
-446
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
@@ -32,14 +34,26 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
|
||||
|
||||
func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string) error {
|
||||
t.Helper()
|
||||
command, _, err := newChatCommand().Find(path)
|
||||
InitDeps(&scriptedToolCaller{})
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.PersistentFlags().Bool("debug", false, "")
|
||||
root.PersistentFlags().Bool("verbose", false, "")
|
||||
command, _, err := root.Find(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for name, value := range flags {
|
||||
if err := command.Flags().Set(name, value); err != nil {
|
||||
flag := command.Flag(name)
|
||||
if flag == nil {
|
||||
return fmt.Errorf("no such flag -%s", name)
|
||||
}
|
||||
if err := flag.Value.Set(value); err != nil {
|
||||
return err
|
||||
}
|
||||
flag.Changed = true
|
||||
}
|
||||
return command.RunE(command, nil)
|
||||
}
|
||||
@@ -93,7 +107,7 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
|
||||
hint string
|
||||
}{
|
||||
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil {
|
||||
@@ -113,6 +127,80 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatAliasInstallerRemainingEdges(t *testing.T) {
|
||||
restoreChatManifestExternalVisibleFlags(nil)
|
||||
|
||||
mismatchedRoot := &cobra.Command{Use: "chat"}
|
||||
mismatchedRoot.AddCommand(&cobra.Command{Use: "other"})
|
||||
restoreChatManifestExternalVisibleFlags(mismatchedRoot)
|
||||
|
||||
missingPrimary := &cobra.Command{Use: "leaf"}
|
||||
installChatFlagAliases(missingPrimary, "conversation-id", []string{"group"}, requireChatConversationID)
|
||||
if flag := missingPrimary.Flags().Lookup("group"); flag != nil {
|
||||
t.Fatalf("alias registered without canonical flag: %#v", flag)
|
||||
}
|
||||
|
||||
skipGroup := &cobra.Command{Use: "leaf", RunE: func(cmd *cobra.Command, args []string) error { return nil }}
|
||||
skipGroup.Flags().String("conversation-id", "", "")
|
||||
skipGroup.Flags().String("group-name", "", "")
|
||||
installChatFlagAliases(skipGroup, "conversation-id", []string{"group", "chat"}, requireChatConversationID)
|
||||
if flag := skipGroup.Flags().Lookup("group"); flag != nil {
|
||||
t.Fatalf("group alias registered beside group-name: %#v", flag)
|
||||
}
|
||||
if flag := skipGroup.Flags().Lookup("chat"); flag == nil {
|
||||
t.Fatal("non-group alias was not registered")
|
||||
}
|
||||
|
||||
preRunCalled := false
|
||||
withPreRun := &cobra.Command{
|
||||
Use: "leaf",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
preRunCalled = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return nil },
|
||||
}
|
||||
withPreRun.Flags().String("conversation-id", "", "")
|
||||
installChatFlagAliases(withPreRun, "conversation-id", []string{"group"}, requireChatConversationID)
|
||||
withPreRun.SetArgs([]string{"--group", "cid"})
|
||||
if err := withPreRun.ExecuteContext(context.Background()); err != nil {
|
||||
t.Fatalf("execute with alias and previous PreRunE: %v", err)
|
||||
}
|
||||
if !preRunCalled {
|
||||
t.Fatal("previous PreRunE was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageForwardRequiresMessageID(t *testing.T) {
|
||||
caller := &productExampleCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
command, _, err := root.Find([]string{"message", "forward"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"message-id", "msg-id"} {
|
||||
if flag := command.Flags().Lookup(name); flag != nil && flag.Annotations != nil {
|
||||
delete(flag.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
}
|
||||
if err := command.Flags().Set("src-conversation-id", "src"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := command.Flags().Set("dest-conversation-id", "dest"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = command.RunE(command, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required flag: --message-id") {
|
||||
t.Fatalf("forward missing message id error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("tool calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
@@ -199,9 +287,10 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
|
||||
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
|
||||
{"message", "list-by-ids", "--msg-ids=" + strings.Repeat("id,", 51) + "last"},
|
||||
{"group", "transfer-owner", "--group=cid", "--new-owner=D-owner"},
|
||||
{"group", "transfer-owner", "--group=cid", "--new-owner=DAAAAAAAAAAAiE"},
|
||||
{"group", "update-icon", "--group=cid", "--icon-media-id=@valid"},
|
||||
{"group", "set-history", "--group=cid", "--option=ALL"},
|
||||
{"group", "audit-join-validation", "--group=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
|
||||
{"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
|
||||
{"mark-read", "--conversation-id=cid", "--message-id=mid"},
|
||||
{"text", "translate", "--query=hello", "--to=zh_CN"},
|
||||
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"},
|
||||
@@ -293,7 +382,7 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-card",
|
||||
"--group=cid",
|
||||
"--conversation-id=cid",
|
||||
"--at-open-dingtalk-ids=D1,D2,D1",
|
||||
"--at-all",
|
||||
)
|
||||
@@ -314,13 +403,13 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
|
||||
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
|
||||
{name: "member mention rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-open-dingtalk-ids=D2"}},
|
||||
{name: "at all rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-all"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
|
||||
t.Fatalf("error = %v, want group-only mention validation", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -330,6 +419,168 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendCardHiddenAliasesMapToCanonicalPayload(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "group alias",
|
||||
args: []string{"--group=cid"},
|
||||
want: map[string]any{"openConversationId": "cid"},
|
||||
},
|
||||
{
|
||||
name: "receiver alias",
|
||||
args: []string{"--receiver=DAAAAAAAAAAAiE"},
|
||||
want: map[string]any{"receiverOpenDingTalkId": "DAAAAAAAAAAAiE"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, tc.want) {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationUsesCanonicalAndAliasPayload(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flag string
|
||||
}{
|
||||
{name: "canonical conversation-id", flag: "--conversation-id=cid"},
|
||||
{name: "hidden group alias", flag: "--group=cid"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"group", "audit-join-validation",
|
||||
tc.flag,
|
||||
"--record-id=123",
|
||||
"--applicant=D-applicant",
|
||||
"--inviter=D-inviter",
|
||||
"--status=AuditDelete",
|
||||
"--description=deny",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"applyRecordId": int64(123),
|
||||
"applicantUid": "D-applicant",
|
||||
"inviterUid": "D-inviter",
|
||||
"status": "AuditDelete",
|
||||
"auditDescription": "deny",
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "audit_join_group" || !reflect.DeepEqual(caller.args, want) {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path []string
|
||||
flag map[string]string
|
||||
want string
|
||||
}{
|
||||
{name: "message list mutually exclusive targets", path: []string{"message", "list"}, flag: map[string]string{"conversation-id": "cid", "user": "u1", "time": "2026-01-01"}, want: "mutually exclusive"},
|
||||
{name: "message list missing target", path: []string{"message", "list"}, flag: map[string]string{"time": "2026-01-01"}, want: "--conversation-id, --user or --open-dingtalk-id is required"},
|
||||
{name: "topic replies missing conversation", path: []string{"message", "list-topic-replies"}, flag: map[string]string{"topic-id": "t1"}, want: "conversation-id"},
|
||||
{name: "read status missing conversation", path: []string{"message", "read-status"}, flag: map[string]string{"message-id": "m1"}, want: "conversation-id"},
|
||||
{name: "read status conflicting aliases", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid1", "group": "cid2", "message-id": "m1"}, want: "conflicts"},
|
||||
{name: "read status missing message", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "update text emotion missing message", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t", "background-id": "b"}, want: "message-id"},
|
||||
{name: "update text emotion missing detail flag", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "message-id": "m1", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t"}, want: "background-id"},
|
||||
{name: "transfer owner missing conversation", path: []string{"group", "transfer-owner"}, flag: map[string]string{"new-owner": "D1"}, want: "conversation-id"},
|
||||
{name: "invite url missing conversation", path: []string{"group", "invite-url"}, want: "conversation-id"},
|
||||
{name: "quit missing conversation", path: []string{"group", "quit"}, want: "conversation-id"},
|
||||
{name: "update icon missing conversation", path: []string{"group", "update-icon"}, flag: map[string]string{"icon-media-id": "@media"}, want: "conversation-id"},
|
||||
{name: "update settings missing conversation", path: []string{"group", "update-settings"}, flag: map[string]string{"setting-key": "searchable"}, want: "conversation-id"},
|
||||
{name: "set admin missing conversation", path: []string{"group", "set-admin"}, flag: map[string]string{"users": "D1"}, want: "conversation-id"},
|
||||
{name: "role list missing conversation", path: []string{"group-role", "list"}, want: "group"},
|
||||
{name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"},
|
||||
{name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"},
|
||||
{name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"},
|
||||
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
|
||||
{name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
|
||||
{name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"},
|
||||
{name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"},
|
||||
{name: "bots rejects migrated conversation id", path: []string{"group", "bots"}, flag: map[string]string{"conversation-id": "cid"}, want: "no such flag"},
|
||||
{name: "dismiss missing conversation", path: []string{"group", "dismiss"}, flag: map[string]string{"yes": "true"}, want: "conversation-id"},
|
||||
{name: "set history missing conversation", path: []string{"group", "set-history"}, flag: map[string]string{"option": "ALL"}, want: "conversation-id"},
|
||||
{name: "set pin missing message", path: []string{"message", "set-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "unset pin missing message", path: []string{"message", "unset-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "audit join missing conversation", path: []string{"group", "audit-join-validation"}, flag: map[string]string{"record-id": "1", "applicant": "D1", "inviter": "D2", "status": "AuditApprove"}, want: "conversation-id"},
|
||||
{name: "set top missing message", path: []string{"message", "set-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "unset top missing message", path: []string{"message", "unset-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "update alias missing conversation", path: []string{"group", "update-alias"}, flag: map[string]string{"alias-title": "alias"}, want: "conversation-id"},
|
||||
{name: "notice create missing conversation", path: []string{"group", "notice", "create"}, flag: map[string]string{"content": "hello"}, want: "conversation-id"},
|
||||
{name: "notice edit missing conversation", path: []string{"group", "notice", "edit"}, flag: map[string]string{"notice-id": "n1", "content": "hello"}, want: "conversation-id"},
|
||||
{name: "notice get missing conversation", path: []string{"group", "notice", "get"}, flag: map[string]string{"notice-id": "n1"}, want: "conversation-id"},
|
||||
{name: "notice list missing conversation", path: []string{"group", "notice", "list"}, want: "conversation-id"},
|
||||
}
|
||||
|
||||
probe := newChatCommand()
|
||||
messageList, _, err := probe.Find([]string{"message", "list"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := chatConversationID(messageList); err != nil || got != "" {
|
||||
t.Fatalf("empty chatConversationID = %q, %v", got, err)
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := runChatCoverageDirect(t, tc.path, tc.flag)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageReadStatusConversationAliasesExecute(t *testing.T) {
|
||||
for _, alias := range []string{"group", "id", "chat", "open-conversation-id"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller, "message", "read-status", "--"+alias, "cid-1", "--message-id", "msg-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.server != "im" || caller.tool != "query_msg_read_status" {
|
||||
t.Fatalf("call = %s/%s, want im/query_msg_read_status", caller.server, caller.tool)
|
||||
}
|
||||
if got := caller.args["openConversationId"]; got != "cid-1" {
|
||||
t.Fatalf("openConversationId = %#v, want cid-1", got)
|
||||
}
|
||||
if got := caller.args["openMessageId"]; got != "msg-1" {
|
||||
t.Fatalf("openMessageId = %#v, want msg-1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
@@ -343,6 +594,7 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--open-dingtalk-id=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--user=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "conversation-info", "--user=u1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--open-dingtalk-id=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--receiver=D1")
|
||||
|
||||
oldGet := httpGetFile
|
||||
|
||||
@@ -17,8 +17,88 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationAliasContract(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "audit-join-validation"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := leaf.Flags().Lookup("conversation-id")
|
||||
if canonical == nil || canonical.Hidden {
|
||||
t.Fatalf("conversation-id flag = %#v, want visible canonical", canonical)
|
||||
}
|
||||
legacy := leaf.Flags().Lookup("group")
|
||||
if legacy == nil || !legacy.Hidden {
|
||||
t.Fatalf("group flag = %#v, want hidden compatibility alias", legacy)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
|
||||
t.Fatalf("group alias_of annotation = %#v", got)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("group alias_origin annotation = %#v", got)
|
||||
}
|
||||
if got := legacy.Annotations[cobra.BashCompOneRequiredFlag]; len(got) != 0 {
|
||||
t.Fatalf("hidden group alias kept required annotation: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationRestoreRequiredNoop(t *testing.T) {
|
||||
restoreChatGroupBotsLegacyRequired(nil)
|
||||
restoreChatPendingMigrationCanonicalRequired(nil)
|
||||
root := &cobra.Command{Use: "chat"}
|
||||
root.AddCommand(&cobra.Command{Use: "other"})
|
||||
restoreChatGroupBotsLegacyRequired(root)
|
||||
restoreChatPendingMigrationCanonicalRequired(root)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupBotsKeepsLegacyGroupFlag(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "bots"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group := leaf.Flags().Lookup("group")
|
||||
if group == nil || group.Hidden {
|
||||
t.Fatalf("group flag = %#v, want visible legacy flag", group)
|
||||
}
|
||||
if got := group.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
|
||||
t.Fatalf("group required annotation = %#v, want true", got)
|
||||
}
|
||||
if leaf.Flags().Lookup("conversation-id") != nil {
|
||||
t.Fatalf("chat group bots still exposes migrated --conversation-id")
|
||||
}
|
||||
if leaf.Flags().Lookup("group-name") != nil {
|
||||
t.Fatalf("chat group bots still exposes migrated --group-name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatPendingMigrationAliasesMatchManifest(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "dismiss"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := leaf.Flags().Lookup("conversation-id")
|
||||
if canonical == nil {
|
||||
t.Fatal("missing conversation-id flag")
|
||||
}
|
||||
if got := canonical.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
|
||||
t.Fatalf("conversation-id required annotation = %#v, want true", got)
|
||||
}
|
||||
legacy := leaf.Flags().Lookup("group")
|
||||
if legacy == nil || !legacy.Hidden {
|
||||
t.Fatalf("group flag = %#v, want hidden legacy alias", legacy)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
|
||||
t.Fatalf("group alias_of annotation = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -51,7 +131,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
|
||||
contains: []string{
|
||||
"send -> query-send-status -> edit",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
@@ -61,7 +141,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
|
||||
contains: []string{
|
||||
"send -> query-send-status -> recall",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
@@ -183,3 +263,91 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *te
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatReactionHelpKeepsManifestExternalAliasesVisible(t *testing.T) {
|
||||
for _, command := range []string{"add-emoji", "remove-emoji", "add-text-emotion", "remove-text-emotion"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", command, "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message %s --help: %v\n%s", command, err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--conversation-id") {
|
||||
t.Fatalf("chat message %s help missing --conversation-id:\n%s", command, help)
|
||||
}
|
||||
for _, visible := range []string{"--group", "--id", "--chat"} {
|
||||
if !strings.Contains(help, visible+" string") {
|
||||
t.Fatalf("chat message %s help hides manifest-external alias %s:\n%s", command, visible, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupBotsHelpKeepsLegacyGroup(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"group", "bots", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat group bots --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--group string") {
|
||||
t.Fatalf("chat group bots help missing visible --group:\n%s", help)
|
||||
}
|
||||
for _, hidden := range []string{"--conversation-id", "--group-name"} {
|
||||
if strings.Contains(help, hidden) {
|
||||
t.Fatalf("chat group bots help exposes migrated flag %s:\n%s", hidden, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendCardHelpUsesCanonicalIDFlags(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", "send-card", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message send-card --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
for _, visible := range []string{"--conversation-id", "--open-dingtalk-id"} {
|
||||
if !strings.Contains(help, visible) {
|
||||
t.Fatalf("send-card help missing %s:\n%s", visible, help)
|
||||
}
|
||||
}
|
||||
for _, visible := range []string{"--group", "--receiver"} {
|
||||
if !strings.Contains(help, visible+" string") {
|
||||
t.Fatalf("send-card help hides manifest-external alias %s:\n%s", visible, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationHelpUsesCanonicalConversationID(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"group", "audit-join-validation", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat group audit-join-validation --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--conversation-id") {
|
||||
t.Fatalf("audit-join-validation help missing --conversation-id:\n%s", help)
|
||||
}
|
||||
if strings.Contains(help, "--group string") {
|
||||
t.Fatalf("audit-join-validation help exposes hidden --group alias:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,32 @@ func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "open-conversation-id alias",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--open-conversation-id", "conv-3",
|
||||
"--message-id", "msg-3",
|
||||
"--old-emotion-id", "old-3",
|
||||
"--emotion-id", "new-3",
|
||||
"--emotion-name", "smile",
|
||||
"--text", "done",
|
||||
"--background-id", "im_bg_2",
|
||||
},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "update_text_emotion",
|
||||
args: map[string]any{
|
||||
"openConversationId": "conv-3",
|
||||
"openMsgId": "msg-3",
|
||||
"oldEmotionId": "old-3",
|
||||
"emotionId": "new-3",
|
||||
"emotionName": "smile",
|
||||
"text": "done",
|
||||
"backgroundId": "im_bg_2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
@@ -127,7 +153,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
{
|
||||
name: "missing conversation-id and aliases",
|
||||
args: dropFlag("--conversation-id"),
|
||||
wantErr: "at least one of the flags in the group [conversation-id group id chat] is required",
|
||||
wantErr: "missing required flag: --conversation-id (or --group / --id / --chat / --open-conversation-id)",
|
||||
},
|
||||
{
|
||||
name: "missing old-emotion-id",
|
||||
@@ -135,7 +161,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
wantErr: `required flag(s) "old-emotion-id" not set`,
|
||||
},
|
||||
{
|
||||
name: "missing msg-id and background-id",
|
||||
name: "missing message-id and background-id",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--conversation-id", "conv-1",
|
||||
@@ -144,7 +170,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
"--emotion-name", "like",
|
||||
"--text", "nice",
|
||||
},
|
||||
wantErr: `required flag(s) "background-id", "msg-id" not set`,
|
||||
wantErr: "missing required flag: --message-id (or --msg-id / --open-message-id)",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -163,19 +189,41 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfig(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "get-mute-config", "--group", "conv-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get-mute-config returned error: %v", err)
|
||||
}
|
||||
want := guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want guardedMutationCall
|
||||
}{
|
||||
{
|
||||
name: "legacy group alias",
|
||||
args: []string{"group", "get-mute-config", "--group", "conv-1"},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "canonical conversation id",
|
||||
args: []string{"group", "get-mute-config", "--conversation-id", "conv-2"},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-2"},
|
||||
},
|
||||
},
|
||||
} {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("get-mute-config returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
|
||||
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,12 +244,12 @@ func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresConversationID(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "get-mute-config")
|
||||
if err == nil || !strings.Contains(err.Error(), "--group") {
|
||||
t.Fatalf("err = %v, want message containing --group", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "--conversation-id") {
|
||||
t.Fatalf("err = %v, want message containing --conversation-id", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %#v, want none", caller.calls)
|
||||
|
||||
+274
-1
@@ -1226,7 +1226,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
|
||||
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
|
||||
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥;扫描触发 2000 条上限或途中目录读取失败时报错,不产出不完整的 Top-N (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
|
||||
driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)")
|
||||
driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)")
|
||||
@@ -3299,6 +3299,274 @@ func newDriveCommand() *cobra.Command {
|
||||
RegisterCrossProductAliases(child)
|
||||
}
|
||||
|
||||
driveStatusCmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "比较本地文件夹与钉盘文件夹的差异",
|
||||
Long: `比较本地文件夹与钉盘文件夹的差异:本地取 --local-folder(绝对路径),钉盘取
|
||||
--remote-folder(文件夹 dentryUuid)指向的文件夹,按精确 MD5(默认)或快速
|
||||
modified_time(--quick)逐文件比对。两侧各自递归遍历,rel_path 相对各自根目录。
|
||||
|
||||
输出五类差异:
|
||||
new_local 仅本地存在
|
||||
new_remote 仅钉盘存在
|
||||
modified 两侧都存在且本次检测判定为已变更
|
||||
unchanged 两侧都存在且本次检测判定为未变更
|
||||
unknown 两侧都存在,但 exact 模式下远端无可靠 MD5、无法核对内容(不判 unchanged/modified)
|
||||
|
||||
只比对钉盘 type=file 的二进制文件(跳过在线文档与快捷方式);本地只比对常规文件。`,
|
||||
Example: ` dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid>
|
||||
dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --space-id xxxx
|
||||
dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --quick`,
|
||||
RunE: runDriveStatus,
|
||||
}
|
||||
driveStatusCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
|
||||
driveStatusCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)")
|
||||
driveStatusCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
|
||||
driveStatusCmd.Flags().Bool("quick", false, "快速模式:只比较 modified_time,不计算 MD5 (可选)")
|
||||
|
||||
drivePullCmd := &cobra.Command{
|
||||
Use: "pull",
|
||||
Short: "把钉盘文件夹单向镜像到本地(Drive → 本地)",
|
||||
Long: `递归下载钉盘 --remote-folder 文件夹下所有 type=file 的文件到本地
|
||||
--local-folder 对应路径(子目录自动创建),单向、文件级镜像。
|
||||
|
||||
已存在的本地文件按 --if-exists 处理:
|
||||
skip 默认,安全:本地已存在则保持不动,只新增
|
||||
smart 推荐增量同步:本地 modified_time 已 ≥ 远端时则跳过下载
|
||||
overwrite 总是下载覆盖(Drive 作为权威源)
|
||||
|
||||
该命令会写入本地文件系统,执行前需要用户确认;非交互环境先用 --dry-run
|
||||
预览,确认后以相同参数追加 --yes 执行。
|
||||
|
||||
输出 summary(downloaded/skipped/failed)与逐文件 items。
|
||||
若有文件下载失败,命令以非零退出码退出,结构化结果仍在 stdout。`,
|
||||
Example: ` dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid>
|
||||
dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart
|
||||
dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --space-id xxxx`,
|
||||
RunE: runDrivePull,
|
||||
}
|
||||
drivePullCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
|
||||
drivePullCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)")
|
||||
drivePullCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
|
||||
drivePullCmd.Flags().String("if-exists", "skip", "本地文件已存在时的策略: skip|smart|overwrite;命令会写本地,执行需确认 (可选)")
|
||||
|
||||
drivePushCmd := &cobra.Command{
|
||||
Use: "push",
|
||||
Short: "把本地文件夹单向镜像到钉盘(本地 → Drive)",
|
||||
Long: `递归把本地 --local-folder 下的文件与子目录(含空目录)镜像到钉盘
|
||||
--remote-folder 文件夹:缺失的目录按需创建(已存在则复用,不重建),文件按
|
||||
--if-exists 处理。文件级镜像——只新增/覆盖,不删除远端多余文件。
|
||||
|
||||
已存在的远端文件按 --if-exists 处理:
|
||||
skip 默认,安全:已存在则保持不动,只新增
|
||||
smart 增量同步:远端 modified_time 已 ≥ 本地时跳过,否则走覆盖路径
|
||||
overwrite 覆盖远端同名文件
|
||||
|
||||
该命令会写入钉盘,执行前需要用户确认;非交互环境先用 --dry-run 预览,
|
||||
确认后以相同参数追加 --yes 执行。
|
||||
|
||||
输出 summary(uploaded/skipped/failed,uploaded 含新建与覆盖)与逐条 items
|
||||
(含 folder_created)。若有文件失败,命令以非零退出码退出,结构化结果仍在 stdout。`,
|
||||
Example: ` dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid>
|
||||
dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart
|
||||
dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists overwrite`,
|
||||
RunE: runDrivePush,
|
||||
}
|
||||
drivePushCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
|
||||
drivePushCmd.Flags().String("remote-folder", "", "钉盘目标文件夹 ID (dentryUuid) (必填)")
|
||||
drivePushCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
|
||||
drivePushCmd.Flags().String("if-exists", "skip", "远端文件已存在时的策略: skip|smart|overwrite;命令会写钉盘,执行需确认 (可选)")
|
||||
|
||||
driveSyncCmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "本地文件夹与钉盘文件夹双向同步(本地 ⇄ Drive)",
|
||||
Long: `把本地 --local-folder 与钉盘 --remote-folder 做文件级双向同步:先按精确 MD5
|
||||
(默认)或快速 modified_time(--quick)算出差异,再按方向执行:
|
||||
new_local 仅本地存在 → 上传到钉盘(缺失的远端目录按需创建)
|
||||
new_remote 仅钉盘存在 → 下载到本地
|
||||
modified 两侧都变更 → 按 --on-conflict 解决
|
||||
unchanged 两侧一致 → 不动
|
||||
|
||||
两侧都变更时的 --on-conflict 策略:
|
||||
skip 默认,两侧都不动并保留两边内容
|
||||
remote-wins 拉取远端覆盖本地
|
||||
local-wins 上传本地覆盖远端
|
||||
keep-both 本地文件改名保留,再拉取远端到原路径
|
||||
ask 交互式逐个询问
|
||||
|
||||
exact 模式下远端无可靠 MD5、内容无法核对的文件归入 unknown 并跳过(可改用 --quick)。
|
||||
文件级同步——只新增/覆盖,不删除任何一侧的多余文件。输出 summary(pulled/pushed/
|
||||
skipped/failed)、diff 与逐条 items;有失败则以非零退出码退出,结构化结果仍在 stdout。
|
||||
|
||||
该命令会同时写入本地与钉盘,执行前需要用户确认;非交互环境先用 --dry-run
|
||||
预览,确认后以相同参数追加 --yes 执行。`,
|
||||
Example: ` dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid>
|
||||
dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --on-conflict local-wins
|
||||
dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --quick --on-conflict keep-both`,
|
||||
RunE: runDriveSync,
|
||||
}
|
||||
driveSyncCmd.Flags().String("local-folder", "", "本地文件夹绝对路径 (必填)")
|
||||
driveSyncCmd.Flags().String("remote-folder", "", "钉盘文件夹 ID (dentryUuid) (必填)")
|
||||
driveSyncCmd.Flags().String("space-id", "", "钉盘空间 ID,不传则使用「我的文件」(可选)")
|
||||
driveSyncCmd.Flags().String("on-conflict", "skip", "两侧都变更时的策略: skip|remote-wins|local-wins|keep-both|ask;命令会写双端,执行需确认 (可选)")
|
||||
driveSyncCmd.Flags().Bool("quick", false, "快速模式:只比较 modified_time,不计算 MD5 (可选)")
|
||||
|
||||
DeclareLeafMetadata(driveStatusCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "folder_status",
|
||||
CanonicalPath: "drive.folder_status",
|
||||
CLIPath: "drive status",
|
||||
PrimaryCLIPath: "drive status",
|
||||
},
|
||||
Description: "比较本地文件夹与钉盘文件夹的差异,只读不落盘。",
|
||||
Result: driveFolderStatusResultSpec(),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files, walks the local tree, and compares both sides by MD5 or modification time; no single pinned RPC represents the diff.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "比较本地文件夹与钉盘文件夹的差异,只读不落盘。",
|
||||
UseWhen: []string{"需要先看清本地与钉盘之间哪些文件新增、变更或一致,再决定拉取还是推送时"},
|
||||
AvoidWhen: []string{"只要单个文件的元数据用 drive info;要真正传输文件用 drive pull / push / sync"},
|
||||
Examples: []string{
|
||||
"dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid>",
|
||||
"dws drive status --local-folder /abs/path/repo --remote-folder <dentryUuid> --quick",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "local-folder", Required: boolPtr(true)},
|
||||
{Name: "remote-folder", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
DeclareLeafMetadata(drivePullCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "folder_pull",
|
||||
CanonicalPath: "drive.folder_pull",
|
||||
CLIPath: "drive pull",
|
||||
PrimaryCLIPath: "drive pull",
|
||||
},
|
||||
Description: "把钉盘文件夹单向镜像到本地;写操作需确认,默认跳过本地既有文件。",
|
||||
DryRun: &contract.DryRunSpec{
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
RemoteReads: true,
|
||||
},
|
||||
Result: driveFolderPullResultSpec(),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files and then downloads each file through drive/download_file plus an HTTP GET into a temporary file committed by an atomic rename; no single pinned RPC represents the mirror.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把钉盘文件夹单向镜像到本地;写操作需确认,默认跳过本地既有文件。",
|
||||
UseWhen: []string{"需要把整个钉盘文件夹拉到本地目录时"},
|
||||
AvoidWhen: []string{"只下载单个文件用 drive download;要把本地推到钉盘用 drive push;要双向对齐用 drive sync"},
|
||||
Examples: []string{
|
||||
"dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid>",
|
||||
"dws drive pull --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart --dry-run",
|
||||
},
|
||||
ExampleDispositions: driveFolderStatefulExampleDispositions(),
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "local-folder", Required: boolPtr(true)},
|
||||
{Name: "remote-folder", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
DeclareLeafMetadata(drivePushCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "folder_push",
|
||||
CanonicalPath: "drive.folder_push",
|
||||
CLIPath: "drive push",
|
||||
PrimaryCLIPath: "drive push",
|
||||
},
|
||||
Description: "把本地文件夹单向镜像到钉盘;写操作需确认,默认跳过远端既有文件。",
|
||||
DryRun: &contract.DryRunSpec{
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
RemoteReads: true,
|
||||
},
|
||||
Result: driveFolderPushResultSpec(),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command recursively lists the remote folder through drive/list_files, creates missing folders through drive/create_folder, and uploads each file through drive/get_upload_info plus an HTTP PUT and drive/commit_upload; no single pinned RPC represents the mirror.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把本地文件夹单向镜像到钉盘;写操作需确认,默认跳过远端既有文件。",
|
||||
UseWhen: []string{"需要把整个本地目录推送到钉盘文件夹时"},
|
||||
AvoidWhen: []string{"只上传单个文件用 drive upload;要把钉盘拉到本地用 drive pull;要双向对齐用 drive sync"},
|
||||
Examples: []string{
|
||||
"dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid>",
|
||||
"dws drive push --local-folder /abs/path/repo --remote-folder <dentryUuid> --if-exists smart --dry-run",
|
||||
},
|
||||
ExampleDispositions: driveFolderStatefulExampleDispositions(),
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "local-folder", Required: boolPtr(true)},
|
||||
{Name: "remote-folder", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
DeclareLeafMetadata(driveSyncCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "folder_sync",
|
||||
CanonicalPath: "drive.folder_sync",
|
||||
CLIPath: "drive sync",
|
||||
PrimaryCLIPath: "drive sync",
|
||||
},
|
||||
Description: "本地与钉盘文件夹双向同步;写操作需确认,默认跳过双端冲突。",
|
||||
DryRun: &contract.DryRunSpec{
|
||||
PreviewKind: contract.DryRunPreviewPlan,
|
||||
RemoteReads: true,
|
||||
},
|
||||
Result: driveFolderSyncResultSpec(),
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command computes the same diff as drive status and then resolves it in both directions through drive/download_file, drive/create_folder, drive/get_upload_info and drive/commit_upload according to --on-conflict; no single pinned RPC represents the bidirectional sync.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "本地与钉盘文件夹双向同步;写操作需确认,默认跳过双端冲突。",
|
||||
UseWhen: []string{"需要让本地目录与钉盘文件夹互相补齐时"},
|
||||
AvoidWhen: []string{"只需单方向镜像用 drive pull / push;只想看差异用 drive status"},
|
||||
Examples: []string{
|
||||
"dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid>",
|
||||
"dws drive sync --local-folder /abs/path/repo --remote-folder <dentryUuid> --on-conflict remote-wins --dry-run",
|
||||
},
|
||||
ExampleDispositions: driveFolderStatefulExampleDispositions(),
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "local-folder", Required: boolPtr(true)},
|
||||
{Name: "remote-folder", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
driveCmd.AddCommand(
|
||||
driveListCmd,
|
||||
driveListSpacesCmd,
|
||||
@@ -3322,6 +3590,11 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermissionCmd,
|
||||
drivePublishCmd,
|
||||
recycleCmd,
|
||||
// 同步命令:status / pull / push / sync
|
||||
driveStatusCmd,
|
||||
drivePullCmd,
|
||||
drivePushCmd,
|
||||
driveSyncCmd,
|
||||
driveStarCmd,
|
||||
driveCoverCmd,
|
||||
driveRevertCmd,
|
||||
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -296,6 +299,11 @@ bfs:
|
||||
|
||||
if folderErr != nil {
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
if latest > 0 {
|
||||
// 不完整集合上的 Top-N 会被误读为全局最新:latest 下不吐 partial,
|
||||
// 直接回根因错误(auth 过期 / 网络不可达比通用 token 更可操作)。
|
||||
return folderErr
|
||||
}
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil {
|
||||
@@ -331,17 +339,206 @@ bfs:
|
||||
}
|
||||
}
|
||||
|
||||
if truncated && latest > 0 {
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
|
||||
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
|
||||
}
|
||||
// BFS 序与修改时间无关:截断与递归途中目录失败都让未扫区域可能含更新文件,
|
||||
// 此时的 Top-N 不是全局最新,两者同属一条防线——拒绝以成功状态产出。
|
||||
if latest > 0 && (truncated || len(errs) > 0) {
|
||||
return driveLatestIncompleteError(latest, truncated, errs, driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID))
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
|
||||
// driveLatestScope 是原调用的完整候选集快照,用于生成不改变候选集的恢复命令。
|
||||
//
|
||||
// 恢复命令若丢掉任何一项,用户照抄后都会在**另一个集合**上拿到一份「看起来对」的 Top-N ——
|
||||
// 比直接报错更难发现:丢 --workspace/--space-id 会从知识库切到普通钉盘(或反之);丢 --folder
|
||||
// 会从子树跳到空间根;丢 --pattern/--type/--start/--end 会把全部条目纳入排序基。
|
||||
type driveLatestScope struct {
|
||||
// domain 是查询域 flag 串(如 "--workspace ws-1" / "--space-id sp-1"),无则空串。
|
||||
domain string
|
||||
// filters 是决定候选集的过滤 flag 串(--pattern/--type/--start/--end),无则空串。
|
||||
filters string
|
||||
// folder 是原调用实际使用的扫描根(已解析的 ID,非用户原始 URL),空则为空间根。
|
||||
folder string
|
||||
// depth 是原调用的 --depth 层数,让「去掉 --latest 重跑」的恢复命令给出确切层数。
|
||||
depth int
|
||||
// notes 收集无法安全内联进可执行命令的原值展示行。POSIX 构建下恒为空(单引号足够);
|
||||
// Windows 构建下含元字符的值走这里,命令里只留占位符。
|
||||
notes []string
|
||||
// 以上 domain/filters/folder 里的值全部经 driveLatestScopeValue 渲染:恢复命令是给用户
|
||||
// 直接复制到 shell 执行的,而 workspace 的常见形态就是带 & 查询串的 URL,pattern 又天然
|
||||
// 含 * 与中文,裸拼接会改变命令解析。
|
||||
}
|
||||
|
||||
// driveLatestValueRenderer 把用户值渲染成可内联的命令片段;ok 为 false 表示该值在目标 shell
|
||||
// 下无法安全内联。取成参数而非直接调用平台绑定函数,是为了让任一平台的测试都能驱动另一平台
|
||||
// 的降级分支 —— 否则「Windows 上降级为占位符」这条路在 POSIX 机器上永不可达、无法验证。
|
||||
type driveLatestValueRenderer func(string) (string, bool)
|
||||
|
||||
// value 渲染单个用户值供内联。值在目标 shell 下无法安全内联时,登记一条展示行并返回占位符
|
||||
// —— 宁可让用户手动粘一次,也不能给出一条粘贴即执行额外命令的「恢复命令」。
|
||||
// 展示行用 strconv.Quote 包裹并显式声明非可执行,与 internal/auth 侧展示 profile 标识一致。
|
||||
func (s *driveLatestScope) value(render driveLatestValueRenderer, label, v string) string {
|
||||
if inline, ok := render(v); ok {
|
||||
return inline
|
||||
}
|
||||
s.notes = append(s.notes, fmt.Sprintf("%s 原值(仅作数据展示,不是可执行命令)%s", label, strconv.Quote(v)))
|
||||
return driveLatestUnsafeValuePlaceholder
|
||||
}
|
||||
|
||||
// driveLatestUnsafeValuePlaceholder 是不可内联值在命令中的占位符。
|
||||
const driveLatestUnsafeValuePlaceholder = "<见下方原值>"
|
||||
|
||||
// driveLatestScopeFromCmd 从原命令抽完整候选集。--workspace 决定路由(知识库 vs 钉盘),判定与
|
||||
// drive list 里的路由分支同源(同一个 flagOrFallback(cmd, "workspace", "workspace-id"));
|
||||
// 钉盘侧的 --space-id 同样必须保留。目录 flag 名无需按路由切换:--folder 两条路由都接受。
|
||||
//
|
||||
// rootFolder 取 runDriveListDepth 实际使用的扫描根而非重新读 flag:用户可能传的是 URL,
|
||||
// 解析后的 ID 才是真正被扫描的目标,也是照抄时更精确的形态。
|
||||
func driveLatestScopeFromCmd(cmd *cobra.Command, depth int, rootFolder string) driveLatestScope {
|
||||
return driveLatestScopeFrom(cmd, depth, rootFolder, driveLatestScopeValue)
|
||||
}
|
||||
|
||||
// driveLatestScopeFrom 是 driveLatestScopeFromCmd 的可注入本体:render 决定用户值以何种形态
|
||||
// 进入恢复命令。生产路径固定传平台绑定的 driveLatestScopeValue;测试可传另一平台的策略,
|
||||
// 从而在单一平台上覆盖两种形态。
|
||||
func driveLatestScopeFrom(cmd *cobra.Command, depth int, rootFolder string, render driveLatestValueRenderer) driveLatestScope {
|
||||
scope := driveLatestScope{depth: depth}
|
||||
if workspaceID := flagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
|
||||
scope.domain = "--workspace " + scope.value(render, "--workspace", workspaceID)
|
||||
} else if spaceID, _ := cmd.Flags().GetString("space-id"); spaceID != "" {
|
||||
scope.domain = "--space-id " + scope.value(render, "--space-id", spaceID)
|
||||
}
|
||||
if rootFolder != "" {
|
||||
scope.folder = scope.value(render, "--folder", rootFolder)
|
||||
}
|
||||
// 顺序固定为注册顺序,保证同一组入参每次给出同一条恢复命令(便于用户比对与测试断言)。
|
||||
filters := make([]string, 0, 4)
|
||||
for _, name := range []string{"pattern", "type", "start", "end"} {
|
||||
if v, _ := cmd.Flags().GetString(name); v != "" {
|
||||
filters = append(filters, "--"+name+" "+scope.value(render, "--"+name, v))
|
||||
}
|
||||
}
|
||||
scope.filters = strings.Join(filters, " ")
|
||||
return scope
|
||||
}
|
||||
|
||||
// command 拼一条保留原候选集的恢复命令:查询域 + 指定的 --folder + 原过滤条件。
|
||||
// folderArg 传 "" 表示该条命令不带 --folder(原调用就在空间根时不应凭空塞一个)。
|
||||
func (s driveLatestScope) command(folderArg string) string {
|
||||
parts := make([]string, 0, 4)
|
||||
parts = append(parts, "dws drive list")
|
||||
if s.domain != "" {
|
||||
parts = append(parts, s.domain)
|
||||
}
|
||||
if folderArg != "" {
|
||||
parts = append(parts, "--folder "+folderArg)
|
||||
}
|
||||
if s.filters != "" {
|
||||
parts = append(parts, s.filters)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// driveLatestIncompleteError 是排序基不完整时的拒绝产出错误。截断与目录失败共用
|
||||
// CodeContentTruncated(→ ExitAPI),但 token 分开,便于消费方区分「范围太大」与「读不到」;
|
||||
// 二者同真时两个 token 都带。拒绝产出后 errors[] 不再进 stdout,失败详情必须落在错误消息里,
|
||||
// 否则用户完全瞎。调用点已保证 truncated 与 len(errs)>0 至少一真。
|
||||
func driveLatestIncompleteError(latest int, truncated bool, errs []driveDepthError, scope driveLatestScope) error {
|
||||
// 目录失败详情排在截断之前:BFS 可以先记下可恢复目录错误、再在别的目录撞上 2000 上限,
|
||||
// 此时 permission_denied 这类 reason 是用户唯一能动手修的线索,不能被截断提示吞掉。
|
||||
causes := make([]string, 0, 2)
|
||||
if len(errs) > 0 {
|
||||
causes = append(causes, driveLatestFolderFailureCause(errs))
|
||||
}
|
||||
if truncated {
|
||||
causes = append(causes, fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断", driveDepthMaxItems))
|
||||
}
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("%s,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d",
|
||||
strings.Join(causes, ";同时 "), latest),
|
||||
Suggestion: driveLatestIncompleteSuggestion(latest, truncated, len(errs) > 0, scope),
|
||||
}
|
||||
}
|
||||
|
||||
// driveLatestFolderFailureCause 组装目录失败详情,含首个失败的 folder/depth/reason。
|
||||
// folderName 空回落 folderID,两者都空回落 <root>。
|
||||
//
|
||||
// folderName / folderID / message 三项都是远端可控内容(目录名由共享目录的创建者决定,
|
||||
// message 是服务端错误文本),必须过 driveLatestSafeRemoteText。Reason 不用过:它是
|
||||
// classifyDriveDepthReason 的固定三值映射,与服务端字符串无关。
|
||||
func driveLatestFolderFailureCause(errs []driveDepthError) string {
|
||||
first := errs[0]
|
||||
folder := first.FolderName
|
||||
if folder == "" {
|
||||
folder = first.FolderID
|
||||
}
|
||||
if folder == "" {
|
||||
folder = "<root>"
|
||||
}
|
||||
return fmt.Sprintf("LATEST_SCAN_INCOMPLETE: %d 个目录未读全(首个失败 folder=%s depth=%d reason=%s: %s)",
|
||||
len(errs), driveLatestSafeRemoteText(folder), first.Depth, first.Reason,
|
||||
driveLatestSafeRemoteText(first.Message))
|
||||
}
|
||||
|
||||
// driveLatestSafeRemoteText 把远端可控文本压成可安全嵌进单行 stderr 错误消息的形式。
|
||||
//
|
||||
// 拒绝产出后 errors[] 不再进 stdout,失败详情改走纯文本错误消息 —— 而 JSON 编码会转义的
|
||||
// 控制字符在纯文本里会被终端直接执行:ANSI/OSC 序列可以伪造提示、清屏、隐藏后续内容、改窗口
|
||||
// 标题,在 AI Agent 场景还会污染上下文窗口。latest=0 的既有路径仍把原值放进 errors[] JSON,
|
||||
// 不受影响,也不该受影响(消费方需要原始数据)。
|
||||
//
|
||||
// output.SanitizeForTerminal 负责剥 ANSI/OSC、C0 控制字符与危险 Unicode,但按设计保留 \n 与
|
||||
// \t;本错误消息是单行叙述,故再把这两者折成空格,避免远端换行把一条错误拆成多行伪造输出。
|
||||
func driveLatestSafeRemoteText(s string) string {
|
||||
s = output.SanitizeForTerminal(s)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\t", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// driveLatestIncompleteSuggestion 按实际触发的成因给恢复指引。约束两条:
|
||||
// 1. 每条示例命令都带原查询域(scope.base()),照抄不会切换查询域;
|
||||
// 2. 每个子句的示例命令与该子句正文一致——「去掉 --latest」的子句示例不带 --latest,
|
||||
// 否则照抄复现同一错误。
|
||||
func driveLatestIncompleteSuggestion(latest int, truncated, folderFailed bool, scope driveLatestScope) string {
|
||||
// 「缩小范围」类命令要求用户换一个目录,故 --folder 给占位符;查询域与原过滤条件由
|
||||
// command 一并带上,否则照抄后候选集就变了(例如丢掉 --pattern 会对全部条目取 Top-N)。
|
||||
narrowed := scope.command("<可读子目录ID>")
|
||||
clauses := make([]string, 0, 3)
|
||||
if folderFailed {
|
||||
clauses = append(clauses, "确认目录权限后重试")
|
||||
}
|
||||
switch {
|
||||
case folderFailed && truncated:
|
||||
// 两个成因都要解:既要换到可读目录,也要把范围缩到 2000 条以内。
|
||||
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录、并降低 --depth 层数后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
|
||||
case folderFailed:
|
||||
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
|
||||
default:
|
||||
clauses = append(clauses, fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 %s --latest %d", narrowed, latest))
|
||||
}
|
||||
// partial+errors[] 承诺限定 --depth>1:单层去掉 --latest 会路由回普通单层 list,本就无
|
||||
// errors[] 契约,故该子句只在多层时给出,并直接带上原层数。这是唯一一条「按原范围」命令,
|
||||
// 必须原样带回原 --folder(原调用在空间根时则不带),照抄即复现同一候选集、只是不取 Top-N。
|
||||
if folderFailed && scope.depth > 1 {
|
||||
clauses = append(clauses, fmt.Sprintf("需要看失败明细请去掉 --latest 按原范围重跑(同时输出已扫到的 partial 与 errors[] 明细):%s --depth %d", scope.command(scope.folder), scope.depth))
|
||||
}
|
||||
// Windows 构建下无法安全引用的值不会进入命令(cmd.exe 不把单引号当引号),原值改在此处以
|
||||
// 数据行给出,由用户手动替换占位符 —— 少一次复制粘贴的便利,换掉一条粘贴即执行的命令。
|
||||
if len(scope.notes) > 0 {
|
||||
clauses = append(clauses, fmt.Sprintf("命令中的 %s 请手动替换为 —— %s",
|
||||
driveLatestUnsafeValuePlaceholder, strings.Join(scope.notes, "、")))
|
||||
}
|
||||
return strings.Join(clauses, ";")
|
||||
}
|
||||
|
||||
// emitDriveDepthCancelled 处理 SIGINT 取消:吐已扫到的 partial(truncated=true)后回退出码 130。
|
||||
//
|
||||
// 这里刻意**不**套用 latest 的拒绝产出防线(只有 BFS 尾部 guard 与 unrecoverable 分支走):
|
||||
// 取消是用户主动发起的,退出码 130 本身已明确告知结果不完整,此时 partial 是用户的预期产物而
|
||||
// 非冒充全局最新的误导。sortTime 仍由 emitDriveDepthResult 统一剥除,取消路径不例外。
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth, route, filter); err != nil {
|
||||
return err
|
||||
@@ -389,6 +586,14 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
// sortTime 是内部排序字段(applyDriveListLatest 排 Top-N、applyDriveListFilter 筛时间区间
|
||||
// 时都已用完),任何输出路径都不得泄露进契约。放在这里一处覆盖三条 emit 路径:正常 emit、
|
||||
// SIGINT 取消、unrecoverable partial。
|
||||
// depth/parentId/rel_path 不在此处删——它们是 depth>1 的既有输出契约,
|
||||
// stripDriveDepthDecorations 仅在单层(reqDepth==1)把这套装饰整体剥掉。
|
||||
for _, item := range items {
|
||||
delete(item, "sortTime")
|
||||
}
|
||||
if (latest > 0 || filter.active()) && reqDepth == 1 {
|
||||
stripDriveDepthDecorations(items)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// driveFolderStatefulExampleDispositions 明确记录文件夹镜像示例为什么只做契约校验:
|
||||
// 预览必须读取用户指定的本地目录与真实钉盘目录,无法在隔离的 Agent 示例环境中伪造。
|
||||
// DryRun 能力仍由 ContractFinal 正式发布,命令级测试负责执行并验证零写入预览。
|
||||
func driveFolderStatefulExampleDispositions() []contract.ExampleDisposition {
|
||||
first, second := 0, 1
|
||||
return []contract.ExampleDisposition{
|
||||
{
|
||||
Index: &first,
|
||||
Mode: contract.ExampleDispositionModeContractOnly,
|
||||
ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
|
||||
Reason: "预览需要读取用户指定的真实钉盘目录和本地文件系统状态",
|
||||
Reviewed: true,
|
||||
},
|
||||
{
|
||||
Index: &second,
|
||||
Mode: contract.ExampleDispositionModeContractOnly,
|
||||
ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
|
||||
Reason: "预览需要读取用户指定的真实钉盘目录和本地文件系统状态",
|
||||
Reviewed: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func driveFolderStatusResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"本地文件夹与钉盘文件夹的差异",
|
||||
"properties":{
|
||||
"detection":{"type":"string","description":"差异检测模式","enum":["exact","quick"]},
|
||||
"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},
|
||||
"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},
|
||||
"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},
|
||||
"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},
|
||||
"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}
|
||||
},
|
||||
"required":["detection","new_local","new_remote","modified","unchanged","unknown"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func driveFolderPullResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomePartialFailure,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"钉盘文件夹拉取的执行结果或预览计划",
|
||||
"properties":{
|
||||
"summary":{"type":"object","description":"真实执行的动作计数","properties":{"downloaded":{"type":"integer","description":"成功下载的文件数"},"skipped":{"type":"integer","description":"按策略跳过的文件数"},"failed":{"type":"integer","description":"失败的文件数"}},"required":["downloaded","skipped","failed"],"additionalProperties":false},
|
||||
"items":{"type":"array","description":"真实执行的逐文件结果","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"},"action":{"type":"string","description":"执行动作"},"error":{"type":"string","description":"失败原因"}},"required":["rel_path","action"],"additionalProperties":false}},
|
||||
"dry_run":{"type":"boolean","description":"是否为只读预览"},
|
||||
"executed":{"type":"boolean","description":"是否执行了写操作"},
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令"},
|
||||
"if_exists":{"type":"string","description":"本地同名文件处理策略"},
|
||||
"plan":{"type":"object","description":"预览的拉取计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"downloaded":{"type":"integer","description":"计划下载的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的文件数"}},"required":["downloaded","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐文件动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"},"action":{"type":"string","description":"计划动作"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func driveFolderPushResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomePartialFailure,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"本地文件夹推送的执行结果或预览计划",
|
||||
"properties":{
|
||||
"summary":{"type":"object","description":"真实执行的动作计数","properties":{"uploaded":{"type":"integer","description":"成功上传或覆盖的文件数"},"skipped":{"type":"integer","description":"按策略跳过的文件数"},"failed":{"type":"integer","description":"失败的条目数"},"aborted":{"type":"boolean","description":"是否因失败中止后续上传"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},
|
||||
"items":{"type":"array","description":"真实执行的逐条目结果","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"执行动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"失败原因"}},"required":["rel_path","action"],"additionalProperties":false}},
|
||||
"dry_run":{"type":"boolean","description":"是否为只读预览"},
|
||||
"executed":{"type":"boolean","description":"是否执行了写操作"},
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令"},
|
||||
"if_exists":{"type":"string","description":"远端同名文件处理策略"},
|
||||
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func driveFolderSyncResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomePartialFailure,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"本地与钉盘双向同步的执行结果或预览计划",
|
||||
"properties":{
|
||||
"detection":{"type":"string","description":"差异检测模式","enum":["exact","quick"]},
|
||||
"diff":{"type":"object","description":"同步前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},
|
||||
"summary":{"type":"object","description":"真实执行的动作计数","properties":{"pulled":{"type":"integer","description":"成功拉取的条目数"},"pushed":{"type":"integer","description":"成功推送的条目数"},"skipped":{"type":"integer","description":"跳过的条目数"},"failed":{"type":"integer","description":"失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},
|
||||
"items":{"type":"array","description":"真实执行的逐条目结果","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"执行动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}},
|
||||
"dry_run":{"type":"boolean","description":"是否为只读预览","const":true},
|
||||
"executed":{"type":"boolean","description":"是否执行了写操作","const":false},
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令","const":"drive sync"},
|
||||
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"oneOf":[
|
||||
{"required":["detection","diff","summary","items"]},
|
||||
{"required":["dry_run","executed","preview_kind","operation","plan"]}
|
||||
],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *testing.T) {
|
||||
drive := newDriveCommand()
|
||||
want := map[string]struct {
|
||||
canonical string
|
||||
dryRun bool
|
||||
outcomes []contract.ResultOutcome
|
||||
properties []string
|
||||
}{
|
||||
"status": {
|
||||
canonical: "drive.folder_status",
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
properties: []string{"detection", "modified", "new_local", "new_remote", "unchanged", "unknown"},
|
||||
},
|
||||
"pull": {
|
||||
canonical: "drive.folder_pull",
|
||||
dryRun: true,
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePartialFailure, contract.ResultOutcomeFailure},
|
||||
properties: []string{"dry_run", "executed", "if_exists", "items", "operation", "plan", "preview_kind", "summary"},
|
||||
},
|
||||
"push": {
|
||||
canonical: "drive.folder_push",
|
||||
dryRun: true,
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePartialFailure, contract.ResultOutcomeFailure},
|
||||
properties: []string{"dry_run", "executed", "if_exists", "items", "operation", "plan", "preview_kind", "summary"},
|
||||
},
|
||||
"sync": {
|
||||
canonical: "drive.folder_sync",
|
||||
dryRun: true,
|
||||
outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePartialFailure, contract.ResultOutcomeFailure},
|
||||
properties: []string{"detection", "diff", "dry_run", "executed", "items", "operation", "plan", "preview_kind", "summary"},
|
||||
},
|
||||
}
|
||||
|
||||
for name, expectation := range want {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
leaf, _, err := drive.Find([]string{name})
|
||||
if err != nil || leaf == nil {
|
||||
t.Fatalf("find drive %s: command=%v err=%v", name, leaf, err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if !ok || final.Identity == nil || final.Identity.CanonicalPath != expectation.canonical {
|
||||
t.Fatalf("drive %s ContractFinal identity = %#v", name, final.Identity)
|
||||
}
|
||||
if final.Result == nil || !reflect.DeepEqual(final.Result.Outcomes, expectation.outcomes) {
|
||||
t.Fatalf("drive %s result = %#v, want outcomes %#v", name, final.Result, expectation.outcomes)
|
||||
}
|
||||
properties := resultSchemaProperties(t, final.Result.DataSchema)
|
||||
if got := sortedContractSchemaKeys(properties); !reflect.DeepEqual(got, expectation.properties) {
|
||||
t.Fatalf("drive %s result properties = %#v, want %#v", name, got, expectation.properties)
|
||||
}
|
||||
if expectation.dryRun {
|
||||
if final.DryRun == nil || final.DryRun.PreviewKind != contract.DryRunPreviewPlan || !final.DryRun.RemoteReads {
|
||||
t.Fatalf("drive %s dry_run = %#v, want remote-reading plan", name, final.DryRun)
|
||||
}
|
||||
if final.Selection == nil || len(final.Selection.ExampleDispositions) != len(final.Selection.Examples) {
|
||||
t.Fatalf("drive %s stateful example dispositions = %#v", name, final.Selection)
|
||||
}
|
||||
for _, disposition := range final.Selection.ExampleDispositions {
|
||||
if disposition.Mode != contract.ExampleDispositionModeContractOnly ||
|
||||
disposition.ReasonCode != contract.ExampleDispositionReasonStatefulPreflight || !disposition.Reviewed {
|
||||
t.Fatalf("drive %s invalid example disposition: %#v", name, disposition)
|
||||
}
|
||||
}
|
||||
} else if final.DryRun != nil {
|
||||
t.Fatalf("drive %s unexpectedly publishes dry_run: %#v", name, final.DryRun)
|
||||
}
|
||||
|
||||
if name == "sync" {
|
||||
assertDriveSyncResultSchema(t, final.Result.DataSchema)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any {
|
||||
t.Helper()
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("result data_schema is not JSON: %v\n%s", err, raw)
|
||||
}
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result data_schema.properties = %#v, want object", schema["properties"])
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
func sortedContractSchemaKeys(values map[string]any) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) {
|
||||
t.Helper()
|
||||
properties := resultSchemaProperties(t, raw)
|
||||
diff := schemaObjectProperty(t, properties, "diff")
|
||||
assertSchemaRequired(t, diff, "new_local", "new_remote", "modified", "unchanged", "unknown")
|
||||
if got := sortedContractSchemaKeys(schemaProperties(t, diff)); !reflect.DeepEqual(got, []string{"modified", "new_local", "new_remote", "unchanged", "unknown"}) {
|
||||
t.Fatalf("sync diff properties = %#v", got)
|
||||
}
|
||||
|
||||
plan := schemaObjectProperty(t, properties, "plan")
|
||||
assertSchemaRequired(t, plan, "detection", "diff", "summary", "items")
|
||||
planProperties := schemaProperties(t, plan)
|
||||
planDiff := schemaObjectProperty(t, planProperties, "diff")
|
||||
assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown")
|
||||
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oneOf, ok := schema["oneOf"].([]any)
|
||||
if !ok || len(oneOf) != 2 {
|
||||
t.Fatalf("sync result oneOf = %#v, want execution/dry-run alternatives", schema["oneOf"])
|
||||
}
|
||||
}
|
||||
|
||||
func schemaObjectProperty(t *testing.T, properties map[string]any, name string) map[string]any {
|
||||
t.Helper()
|
||||
property, ok := properties[name].(map[string]any)
|
||||
if !ok || property["type"] != "object" {
|
||||
t.Fatalf("schema property %s = %#v, want object", name, properties[name])
|
||||
}
|
||||
return property
|
||||
}
|
||||
|
||||
func schemaProperties(t *testing.T, schema map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
properties, ok := schema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema properties = %#v, want object", schema["properties"])
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
func assertSchemaRequired(t *testing.T, schema map[string]any, want ...string) {
|
||||
t.Helper()
|
||||
raw, ok := schema["required"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema required = %#v", schema["required"])
|
||||
}
|
||||
got := make([]string, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("schema required value = %#v, want string", value)
|
||||
}
|
||||
got = append(got, text)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("schema required = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// 本文件锁定 drive list --latest 的两个 P1 行为,独立于 pr868_*_test.go / drive_depth_test.go:
|
||||
//
|
||||
// P1-a:sortTime 是内部排序字段,任何输出路径都不得泄露进契约;
|
||||
// P1-b:递归途中目录读取失败时,Top-N 建立在不完整集合上,必须拒绝产出而非吐 partial。
|
||||
//
|
||||
// 改代码前这些断言对 origin/main 应为红:main 采集端无条件写 sortTime、emit 仅在单层
|
||||
// latest/filter 才剥;main 尾部拒绝 guard 只拦截断、不拦目录失败。
|
||||
//
|
||||
// 另锁定评审反馈的三个边界:
|
||||
//
|
||||
// 截断与目录失败同真时,失败详情(permission_denied 等)不得被截断提示吞掉;
|
||||
// 拒绝产出时给的恢复命令必须保留原查询域(--workspace / --space-id),照抄不会切换查询域;
|
||||
// 恢复命令里的用户值必须过 argv 引用,URL 查询串与 shell 元字符都不能改变命令解析。
|
||||
//
|
||||
// 关于 TestCrossPlatformCoverage 前缀:这是门禁约定,不是命名风格。平台覆盖率门禁
|
||||
// scripts/policy/run-platform-coverage-gate.sh 只跑
|
||||
// -run '^(TestAllShortcuts|TestCrossPlatformCoverage)',本包新增的生产代码若没有带该前缀的
|
||||
// 测试覆盖,Coverage (macOS) / (Windows) 会以「changed code coverage 低于 100%」失败
|
||||
// (本 PR 改名前实测 69.0476%,84 条可执行语句)。改名务必保留前缀。
|
||||
|
||||
// assertNoSortTime 断言 stdout 每个 item 都不含内部排序字段 sortTime。
|
||||
func assertNoSortTime(t *testing.T, result map[string]any) {
|
||||
t.Helper()
|
||||
items, ok := result["items"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("items missing or wrong type: %#v", result["items"])
|
||||
}
|
||||
for i, raw := range items {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("item[%d] not an object: %#v", i, raw)
|
||||
}
|
||||
if _, leaked := item["sortTime"]; leaked {
|
||||
t.Fatalf("item[%d] leaked internal sortTime into output contract: %#v", i, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestNoSortTimeLeak 覆盖 main 的覆盖漏洞:main 的
|
||||
// TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime 名字带 SortTime,
|
||||
// 却只断言 TRUNCATED、从不检查输出无 sortTime。这里把三条泄露路径都钉住。
|
||||
func TestCrossPlatformCoverageDriveLatestNoSortTimeLeak(t *testing.T) {
|
||||
twoFiles := `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifiedTime":1000},{"fileId":"f2","name":"b.txt","type":"FILE","modifiedTime":2000}]}`
|
||||
|
||||
// 场景 A:depth>1 --latest —— 走 applyDriveListLatest(只读 sortTime、不剥),reqDepth>1 不触发 strip。
|
||||
t.Run("depth_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
assertNoSortTime(t, decodeDepthResult(t, out))
|
||||
})
|
||||
|
||||
// 场景 B:--depth 2 无 latest 无 filter —— 走 else 分支树序排序,同样不触发 strip。
|
||||
t.Run("depth_no_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
assertNoSortTime(t, decodeDepthResult(t, out))
|
||||
})
|
||||
|
||||
// 场景 C:--depth 2 --type file —— #971 引入的 filter 也读 sortTime(applyDriveListFilter),
|
||||
// strip 条件 (latest>0||filter.active()) && reqDepth==1 在多层下依旧不成立,泄露面因此变大。
|
||||
t.Run("depth_filter_no_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{nodeType: "file"}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
if len(result["items"].([]any)) != 2 {
|
||||
t.Fatalf("filter 应保留两个 FILE: %#v", result["items"])
|
||||
}
|
||||
assertNoSortTime(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime 同时承担两件事:
|
||||
// 1. P1-a 的第四条路径 —— SIGINT 取消也走 emitDriveDepthResult,同样不得泄露 sortTime;
|
||||
// 2. SIGINT 契约锁 —— 本次 P1-b 只在 BFS 尾部 guard 与 unrecoverable 分支生效,**不改**
|
||||
// 取消路径:SIGINT 是用户主动中断、退出码 130 已明确告知不完整,partial 是明确预期。
|
||||
// 这条测试防止后续误把 fail-closed 扩到取消路径,也为外部评测用例
|
||||
// test_sigint_exits_130_with_partial 提供本地对照。
|
||||
func TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
out := installDepthCaller(t, caller)
|
||||
items := []map[string]any{
|
||||
{"fileId": "f1", "name": "a.txt", "type": "FILE", "sortTime": int64(2000), "rel_path": "a.txt", "depth": 2},
|
||||
}
|
||||
errs := []driveDepthError{
|
||||
{Depth: 1, FolderID: "fid-1", FolderName: "半路目录", Reason: "permission_denied", Message: "denied"},
|
||||
}
|
||||
// latest>0 且 reqDepth>1:main 在此不 strip,sortTime 泄露。
|
||||
err := emitDriveDepthCancelled(items, errs, "", 5, 3, newDrivePanDepthRoute(), driveListFilter{})
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
}
|
||||
if cancelErr.ExitCode() != 130 {
|
||||
t.Fatalf("exit code = %d, want 130", cancelErr.ExitCode())
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
// partial 契约不变:items 与 errors 都照吐,truncated 标记为真。
|
||||
if len(result["items"].([]any)) != 1 {
|
||||
t.Fatalf("取消路径应保留 partial items: %#v", result["items"])
|
||||
}
|
||||
if len(result["errors"].([]any)) != 1 {
|
||||
t.Fatalf("取消路径应保留 errors[]: %#v", result["errors"])
|
||||
}
|
||||
if result["truncated"] != true {
|
||||
t.Fatalf("取消结果 truncated = %#v, want true", result["truncated"])
|
||||
}
|
||||
assertNoSortTime(t, result)
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure 覆盖 P1-b:递归途中一个可恢复目录失败(403/business,
|
||||
// 非 auth 非限流 → 记 errs[] 跳过),Top-N 落在不完整集合上,必须拒绝产出。
|
||||
// 构造:根目录成功产出 FOLDER+FILE(collected>0 且 dirA 入队),子目录返回 forbidden.* →
|
||||
// recoverable → errs=[1]。旧代码尾部 `if truncated && latest>0` 不触发 → emit 吐 partial(err=nil);
|
||||
// 新代码 `len(errs)>0` → LATEST_SCAN_INCOMPLETE 且 stdout 无 items。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("err = %v, want LATEST_SCAN_INCOMPLETE", err)
|
||||
}
|
||||
// 拒绝产出:stdout 必须没有 items(不是 partial)。旧代码此处会吐 partial,断言随之失败。
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure 端到端证明「截断 + 目录失败」组合确实可达:
|
||||
// 根目录出 dirA/dirB 两个子目录 → dirA 权限失败记 errs[] → dirB 返回 2000 条触发全局截断,
|
||||
// 尾部 guard 拿到 truncated=true 且 len(errs)=1。旧实现在此让 truncated 短路,permission_denied
|
||||
// 详情整块丢失(评审反馈的阻塞点);现在两个 token 与失败详情都必须在。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
var bulk strings.Builder
|
||||
bulk.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
bulk.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&bulk, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
bulk.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"报表","type":"FOLDER"},{"fileId":"dirB","name":"dirB","type":"FOLDER"}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`}, // dirA:可恢复 → 记 errs[]
|
||||
{text: bulk.String()}, // dirB:撞 2000 上限 → truncated
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
if !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("组合场景缺 TRUNCATED token: %q", msg)
|
||||
}
|
||||
// 旧实现在这里丢掉整段目录失败详情。
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
|
||||
!strings.Contains(msg, "folder=报表") ||
|
||||
!strings.Contains(msg, "permission_denied") {
|
||||
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand 端到端验证查询域从原命令一路带到恢复命令:单测构造器
|
||||
// 拿不到的是 runDriveListDepth 里的接线(driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID)),这里补上。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"})
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{"spaceId": "sp-7"}, "", 4, "", true, 5, driveListFilter{})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Fatalf("err = %T %v", err, err)
|
||||
}
|
||||
// 恢复命令必须带原 --space-id,且给出原 --depth 层数。
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "--space-id sp-7")
|
||||
if !strings.Contains(cliErr.Suggestion, "--depth 4") {
|
||||
t.Fatalf("恢复命令应保留原层数: %q", cliErr.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure 覆盖 P1-b 的另一半:递归途中遇不可恢复错误
|
||||
// (auth 过期 / 网络不可达)且 latest>0 时,不吐 partial,直接回根因错误。
|
||||
// 与 latest=0 的既有行为(TestCrossPlatformCoverageRunDriveListDepthUnrecoverable:partial
|
||||
// + errors[] 进 stdout 后非零退出)对照——latest 下 partial 的 Top-N 会被误读为全局最新,
|
||||
// 故必须拒绝产出;回根因错误而非 INCOMPLETE token,因为 auth/网络比通用截断提示更可操作。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
// 回根因错误:Code 仍是 auth 过期,不被包装成 LATEST_SCAN_INCOMPLETE。
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired {
|
||||
t.Fatalf("err = %T %v, want CodeAuthTokenExpired", err, err)
|
||||
}
|
||||
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("unrecoverable 应回根因错误而非 INCOMPLETE 包装: %q", cliErr.Message)
|
||||
}
|
||||
// 拒绝产出:不吐 partial(对照 latest=0 时会输出 2 条 items + 1 条 errors)。
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches 直接单测构造器的各条分支。
|
||||
func TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches(t *testing.T) {
|
||||
// 纯截断分支:errs 为空 → 只有 TRUNCATED。
|
||||
t.Run("truncated_only", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, true, nil, driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
if !strings.Contains(cliErr.Message, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("message = %q", cliErr.Message)
|
||||
}
|
||||
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("无目录失败时不应出现 INCOMPLETE: %q", cliErr.Message)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
})
|
||||
|
||||
// 纯目录失败分支:未截断 → 只有 INCOMPLETE,Message 含首个失败的 folder/depth/reason。
|
||||
t.Run("folder_failure_only", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(3, false, twoDriveDepthErrors(), driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
|
||||
!strings.Contains(msg, "报表") ||
|
||||
!strings.Contains(msg, "depth=2") ||
|
||||
!strings.Contains(msg, "permission_denied") ||
|
||||
!strings.Contains(msg, "2 个目录未读全") {
|
||||
t.Fatalf("message = %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("未截断时不应出现 TRUNCATED: %q", msg)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
})
|
||||
|
||||
// 组合分支(评审反馈的阻塞点):截断与目录失败同真时,旧实现让 truncated 短路、
|
||||
// permission_denied 这类目录失败详情整块丢失。现在两个 token 都必须在,且失败详情不得被吞。
|
||||
t.Run("truncated_with_folder_failures", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(4, true, twoDriveDepthErrors(), driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
// 两个成因都要可被消费方 token 匹配到。
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") || !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("组合场景需同时带两个 token: %q", msg)
|
||||
}
|
||||
// 目录失败详情必须完整保留——这是用户唯一能动手修的线索。
|
||||
if !strings.Contains(msg, "报表") ||
|
||||
!strings.Contains(msg, "depth=2") ||
|
||||
!strings.Contains(msg, "permission_denied") ||
|
||||
!strings.Contains(msg, "2 个目录未读全") {
|
||||
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "拒绝输出不完整的 Top-4") {
|
||||
t.Fatalf("message 缺少拒绝产出结论: %q", msg)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
// 组合场景的指引要同时覆盖两条补救:换可读目录 + 降层数。
|
||||
if !strings.Contains(cliErr.Suggestion, "确认目录权限") || !strings.Contains(cliErr.Suggestion, "--depth") {
|
||||
t.Fatalf("组合场景 suggestion 需同时给出权限与降层数补救: %q", cliErr.Suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// folderName 为空回落 folderID;folderID 也空回落 <root>。
|
||||
t.Run("folder_fallback", func(t *testing.T) {
|
||||
byID := driveLatestIncompleteError(1, false, []driveDepthError{{FolderID: "fid-x"}}, driveLatestScope{depth: 2})
|
||||
if !strings.Contains(byID.Error(), "folder=fid-x") {
|
||||
t.Fatalf("fallback to folderID: %v", byID)
|
||||
}
|
||||
byRoot := driveLatestIncompleteError(1, false, []driveDepthError{{}}, driveLatestScope{depth: 2})
|
||||
if !strings.Contains(byRoot.Error(), "folder=<root>") {
|
||||
t.Fatalf("fallback to <root>: %v", byRoot)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// twoDriveDepthErrors 是两条目录失败样本,首条用于断言「首个失败」详情。
|
||||
func twoDriveDepthErrors() []driveDepthError {
|
||||
return []driveDepthError{
|
||||
{Depth: 2, FolderID: "fid-9", FolderName: "报表", Reason: "permission_denied", Message: "denied"},
|
||||
{Depth: 1, FolderID: "fid-3", FolderName: "归档", Reason: "api_error", Message: "boom"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion 覆盖评审反馈的第二个阻塞点:恢复命令此前固定
|
||||
// 生成 `dws drive list --folder ...`,把原调用的 --workspace / --space-id 丢掉。用户照抄后
|
||||
// 会从知识库切到普通钉盘(或从指定钉盘空间切到「我的文件」),在另一个查询域里拿到一份
|
||||
// 「看起来对」的 Top-N —— 比直接报错更难发现。
|
||||
func TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
want string
|
||||
}{
|
||||
// 知识库路由:--workspace 决定路由,丢了就切到普通钉盘。
|
||||
{name: "workspace", flags: map[string]string{"workspace": "ws-1"}, want: "--workspace ws-1"},
|
||||
// 别名路径:--workspace-id 与 --workspace 同源(flagOrFallback)。
|
||||
{name: "workspace_id_alias", flags: map[string]string{"workspace-id": "ws-alias"}, want: "--workspace ws-alias"},
|
||||
// 钉盘路由:--space-id 丢了就退回「我的文件」。
|
||||
{name: "space_id", flags: map[string]string{"space-id": "sp-7"}, want: "--space-id sp-7"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, tc.flags), 3, "")
|
||||
// 三条成因组合下的恢复命令都必须带原查询域。
|
||||
for _, variant := range []struct {
|
||||
label string
|
||||
truncated bool
|
||||
errs []driveDepthError
|
||||
}{
|
||||
{"truncated_only", true, nil},
|
||||
{"folder_failure_only", false, twoDriveDepthErrors()},
|
||||
{"both", true, twoDriveDepthErrors()},
|
||||
} {
|
||||
err := driveLatestIncompleteError(5, variant.truncated, variant.errs, scope)
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Fatalf("%s: err = %T %v", variant.label, err, err)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --workspace 优先于 --space-id:与 drive list 的路由判定同序(先看 workspace 再看 space-id),
|
||||
// 否则恢复命令会把知识库查询写成钉盘查询。
|
||||
t.Run("workspace_wins_over_space_id", func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{
|
||||
"workspace": "ws-1", "space-id": "sp-7",
|
||||
}), 3, "")
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, "--workspace ws-1") || strings.Contains(suggestion, "--space-id") {
|
||||
t.Fatalf("workspace 应优先且不混入 space-id: %q", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// 无查询域时不得凭空造 flag(原调用就是「我的文件」根,硬塞 scope 同样是改查询域)。
|
||||
t.Run("no_scope_adds_nothing", func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, nil), 3, "")
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if strings.Contains(suggestion, "--workspace") || strings.Contains(suggestion, "--space-id") {
|
||||
t.Fatalf("无 scope 时不应凭空造查询域 flag: %q", suggestion)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, suggestion, "")
|
||||
})
|
||||
|
||||
// depth==1(知识库 --latest 单层)时不给 --depth 1:partial+errors[] 契约只在多层成立,
|
||||
// 硬塞 --depth 1 会让「去掉 --latest 看明细」的子句自相矛盾。
|
||||
t.Run("single_depth_omits_depth_flag", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--workspace ws-1", depth: 1})
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if strings.Contains(suggestion, "--depth") {
|
||||
t.Fatalf("单层不应出现 --depth: %q", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// 多层时给出确切层数,用户无需把 <原层数> 换成数字。
|
||||
t.Run("multi_depth_emits_actual_depth", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--space-id sp-7", depth: 4})
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, "--depth 4") {
|
||||
t.Fatalf("应给出原层数 --depth 4: %q", suggestion)
|
||||
}
|
||||
if strings.Contains(suggestion, "<原层数>") {
|
||||
t.Fatalf("不应残留占位符: %q", suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues 覆盖评审的 P1 阻断项:恢复命令是给用户直接复制到
|
||||
// shell 里执行的,查询域的值来自用户输入(workspace 常见形态就是带查询串的 URL)。未引用时
|
||||
// 一个 `&` 就把命令拆成后台任务,`;` / `$()` 更能执行额外内容。
|
||||
// 断言落在「值被完整包在单引号里」而不只是「出现过」——后者对裸拼接也成立,抓不住缺陷。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flag string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
// 合法 workspace URL:& 在裸拼接下直接改变 shell 解析(前半段被丢进后台)。
|
||||
{name: "workspace_url", flag: "workspace", value: "https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc",
|
||||
want: `--workspace 'https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc'`},
|
||||
// 命令替换:裸拼接会在用户复制执行时真的跑起来。
|
||||
{name: "command_substitution", flag: "workspace", value: "$(id)", want: `--workspace '$(id)'`},
|
||||
// 分号拆语句。
|
||||
{name: "semicolon", flag: "space-id", value: "sp-7;id", want: `--space-id 'sp-7;id'`},
|
||||
// 空格拆参:裸拼接会让 --folder 收到错误的值。
|
||||
{name: "space", flag: "space-id", value: "sp 7", want: `--space-id 'sp 7'`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 显式注入 POSIX 策略:本测试断言的是「危险值被单引号正确包住」这一 POSIX 形态。
|
||||
// Windows 策略下这些值根本不内联(见 ...SuggestionNeverInlinesHostileValue),
|
||||
// 若走平台绑定,这里在 Windows runner 上会因形态不同而误报。
|
||||
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{tc.flag: tc.value}), 3, "", driveLatestPosixScopeValue)
|
||||
err := driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, tc.want) {
|
||||
t.Fatalf("查询域未安全引用\n want substring: %s\n got suggestion: %s", tc.want, suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder 覆盖自动 CR 的 P2:
|
||||
// --pattern/--type/--start/--end 与 --folder 同样决定 --latest 的候选集合。恢复命令丢掉任一项,
|
||||
// 用户照抄后就是在**另一个集合**上取 Top-N —— 原调用带 --pattern 时,缺了它的重试命令会对全部
|
||||
// 条目排序,结果「看起来成功」却答非所问,比直接报错更难发现。
|
||||
func TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder(t *testing.T) {
|
||||
cmd := newDriveListScopeCmd(t, map[string]string{
|
||||
"workspace": "ws-1",
|
||||
"pattern": "*日报*",
|
||||
"type": "file",
|
||||
"start": "7d",
|
||||
"end": "2026-08-01",
|
||||
})
|
||||
// 注入 POSIX 策略:`--pattern '*日报*'` 这种引用形态是 POSIX 专属,Windows 下该值会降级为
|
||||
// 占位符 + 展示行(见 ...SuggestionNeverInlinesHostileValue)。走平台绑定会在 Windows 误报。
|
||||
scope := driveLatestScopeFrom(cmd, 4, "folder-root", driveLatestPosixScopeValue)
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
|
||||
// 每条示例命令都要带齐查询域与全部过滤条件;pattern 含 * 与中文,必须是引用后的形态。
|
||||
for _, want := range []string{
|
||||
"--workspace ws-1",
|
||||
"--pattern '*日报*'",
|
||||
"--type file",
|
||||
"--start 7d",
|
||||
"--end 2026-08-01",
|
||||
} {
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cmdText, want) {
|
||||
t.Fatalf("恢复命令丢失候选集条件 %q:\n clause: %s", want, cmdText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 「去掉 --latest 按原范围重跑」是唯一的原范围命令,必须原样带回原 --folder 而非占位符,
|
||||
// 否则它就不是「原范围」。
|
||||
var origin string
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
if strings.Contains(clause, "去掉 --latest") {
|
||||
origin = extractTrailingDwsCommand(clause)
|
||||
}
|
||||
}
|
||||
if origin == "" {
|
||||
t.Fatalf("多层场景应给出「去掉 --latest 按原范围重跑」子句: %q", suggestion)
|
||||
}
|
||||
if !strings.Contains(origin, "--folder folder-root") {
|
||||
t.Fatalf("原范围命令应保留原 --folder: %q", origin)
|
||||
}
|
||||
if strings.Contains(origin, "<可读子目录ID>") {
|
||||
t.Fatalf("原范围命令不应把原 --folder 换成占位符: %q", origin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot 原调用就在空间根时不得凭空
|
||||
// 造 --folder:硬塞一个目录同样是改候选集。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"}), 3, "")
|
||||
err := driveLatestIncompleteError(2, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
if !strings.Contains(clause, "去掉 --latest") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(extractTrailingDwsCommand(clause), "--folder") {
|
||||
t.Fatalf("空间根扫描的原范围命令不应带 --folder: %q", clause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars 覆盖自动 CR 的 P2 安全项:
|
||||
// 拒绝产出后,目录名与服务端错误文本从 JSON(编码时会被转义)挪进了纯文本 stderr。若原样透传,
|
||||
// 其中的 ANSI/OSC 序列会被终端直接执行 —— 可清屏、伪造彩色「成功」、隐藏后续输出、改窗口标题,
|
||||
// 在 AI Agent 场景还会污染上下文窗口。目录名对共享目录而言是他人可控输入。
|
||||
func TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars(t *testing.T) {
|
||||
assertNoControlChars := func(t *testing.T, msg string) {
|
||||
t.Helper()
|
||||
for name, r := range map[string]rune{"ESC": 0x1b, "BEL": 0x07, "CR": '\r', "LF": '\n', "TAB": '\t'} {
|
||||
if strings.ContainsRune(msg, r) {
|
||||
t.Fatalf("错误消息残留 %s 控制字符: %q", name, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// folderName 路径:CSI 清屏 + 变色,外加 OSC 改标题。
|
||||
t.Run("folder_name", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(3, false, []driveDepthError{{
|
||||
Depth: 2,
|
||||
FolderName: "报表\x1b[2J\x1b[31m看起来成功\x1b[0m",
|
||||
Reason: "permission_denied",
|
||||
Message: "denied\r\n\x1b]0;pwned\x07次行",
|
||||
}}, driveLatestScope{depth: 2})
|
||||
msg := err.(*CLIError).Message
|
||||
assertNoControlChars(t, msg)
|
||||
// 清理不能把诊断信息一起抹掉:可读部分必须留下。
|
||||
if !strings.Contains(msg, "报表") || !strings.Contains(msg, "denied") || !strings.Contains(msg, "次行") {
|
||||
t.Fatalf("清理后应保留可读文本: %q", msg)
|
||||
}
|
||||
})
|
||||
|
||||
// folderName 为空时回落到 folderID,该字段同样来自服务端。
|
||||
t.Run("folder_id_fallback", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(1, true, []driveDepthError{{
|
||||
FolderID: "fid\x1b[1m-x",
|
||||
Reason: "api_error",
|
||||
Message: "boom",
|
||||
}}, driveLatestScope{depth: 1})
|
||||
msg := err.(*CLIError).Message
|
||||
assertNoControlChars(t, msg)
|
||||
if !strings.Contains(msg, "fid-x") {
|
||||
t.Fatalf("剥离控制序列后 folderID 应连成 fid-x: %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSafeRemoteText 直接钉住清理函数的各条分支:换行/制表符
|
||||
// 折成空格(SanitizeForTerminal 按设计保留这两者,但单行错误消息里它们会拆出伪造行),
|
||||
// 首尾空白收掉,可打印内容与中文原样保留。
|
||||
func TestCrossPlatformCoverageDriveLatestSafeRemoteText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "plain", in: "denied", want: "denied"},
|
||||
{name: "cjk", in: "报表目录", want: "报表目录"},
|
||||
{name: "csi", in: "a\x1b[31mb", want: "ab"},
|
||||
{name: "osc", in: "a\x1b]0;t\x07b", want: "ab"},
|
||||
{name: "newline_to_space", in: "a\nb", want: "a b"},
|
||||
{name: "tab_to_space", in: "a\tb", want: "a b"},
|
||||
{name: "carriage_return_dropped", in: "a\rb", want: "ab"},
|
||||
{name: "trim_outer", in: "\n denied \t", want: "denied"},
|
||||
{name: "empty", in: "", want: ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := driveLatestSafeRemoteText(tc.in); got != tc.want {
|
||||
t.Fatalf("driveLatestSafeRemoteText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue 端到端锁定评审提出的
|
||||
// Windows 注入阻断项:`--space-id sp-7&whoami` 这种值,在 POSIX 下靠单引号关停,但 cmd.exe
|
||||
// 不把单引号当引号,粘贴后 `&whoami` 仍会执行。因此不变量必须是「恢复命令里不存在能被目标
|
||||
// shell 解释的裸元字符」,两个平台分别用各自的手段满足:POSIX 引用内联,Windows 不内联。
|
||||
//
|
||||
// 两种形态都通过注入渲染策略在本机验证,不依赖当前 GOOS —— 否则「Windows 上降级为占位符」
|
||||
// 这条路在 POSIX 机器上永不可达,就成了只有 Windows runner 才跑到的盲区(平台覆盖率门禁也会
|
||||
// 因此报未覆盖)。平台绑定本身由 TestCrossPlatformCoverageDriveLatestScopeValueBinding 覆盖。
|
||||
func TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue(t *testing.T) {
|
||||
const hostile = "sp-7&whoami"
|
||||
newSuggestion := func(t *testing.T, render driveLatestValueRenderer) string {
|
||||
t.Helper()
|
||||
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{"space-id": hostile}), 3, "", render)
|
||||
return driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope).(*CLIError).Suggestion
|
||||
}
|
||||
|
||||
t.Run("windows_never_inlines", func(t *testing.T) {
|
||||
suggestion := newSuggestion(t, driveLatestWindowsScopeValue)
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
// cmd.exe 里 & 分隔命令,且单引号不是引号,故它压根不能进命令。
|
||||
if strings.Contains(cmdText, "&") {
|
||||
t.Fatalf("windows 形态的命令不得含 &: %s", cmdText)
|
||||
}
|
||||
if strings.Contains(cmdText, hostile) {
|
||||
t.Fatalf("windows 形态的命令不得内联原值: %s", cmdText)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
|
||||
t.Fatalf("应降级为占位符: %s", suggestion)
|
||||
}
|
||||
if !strings.Contains(suggestion, strconv.Quote(hostile)) {
|
||||
t.Fatalf("应在展示行给出原值: %s", suggestion)
|
||||
}
|
||||
if !strings.Contains(suggestion, "不是可执行命令") {
|
||||
t.Fatalf("展示行须显式声明非可执行: %s", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("posix_quotes_inline", func(t *testing.T) {
|
||||
suggestion := newSuggestion(t, driveLatestPosixScopeValue)
|
||||
if !strings.Contains(suggestion, "'"+hostile+"'") {
|
||||
t.Fatalf("posix 形态应单引号内联原值: %s", suggestion)
|
||||
}
|
||||
// POSIX 下不该无谓降级 —— 那会白白损失可复制体验。
|
||||
if strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
|
||||
t.Fatalf("posix 形态不应降级为占位符: %s", suggestion)
|
||||
}
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cmdText, "&") && !strings.Contains(cmdText, "'"+hostile+"'") {
|
||||
t.Fatalf("posix 形态出现未引用的元字符: %s", cmdText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newDriveListScopeCmd 造一个带 drive list 查询域与过滤 flag 的命令。flag 名与 newDriveCommand
|
||||
// 里 driveListCmd 的注册保持一致;workspace-id 是 cross-product 别名,此处显式注册以覆盖别名路径。
|
||||
func newDriveListScopeCmd(t *testing.T, flags map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
for _, name := range []string{
|
||||
"workspace", "workspace-id", "space-id", // 查询域
|
||||
"folder", // 扫描根(scope 从 rootFolder 参数取,注册仅为对齐真实命令)
|
||||
"pattern", "type", "start", "end", // 决定候选集的过滤条件
|
||||
} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name, value := range flags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s=%s: %v", name, value, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// assertDriveLatestSuggestion 钉住 Suggestion 的三条约束:
|
||||
// 1. 每条示例命令都带原查询域 wantScope(空串表示原调用无查询域,此时只跳过该项检查);
|
||||
// 2. 含 --latest 的引导子句存在;
|
||||
// 3. 「去掉 --latest」子句给出的示例命令本身不带 --latest(否则照抄复现同一错误)。
|
||||
func assertDriveLatestSuggestion(t *testing.T, suggestion, wantScope string) {
|
||||
t.Helper()
|
||||
clauses := strings.Split(suggestion, ";")
|
||||
sawLatestGuide := false
|
||||
for _, clause := range clauses {
|
||||
cmd := extractTrailingDwsCommand(clause)
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
if wantScope != "" && !strings.Contains(cmd, wantScope) {
|
||||
t.Fatalf("示例命令丢失原查询域 %q(照抄会切换查询域): %q", wantScope, cmd)
|
||||
}
|
||||
if strings.Contains(clause, "去掉 --latest") {
|
||||
if strings.Contains(cmd, "--latest") {
|
||||
t.Fatalf("「去掉 --latest」子句的示例命令仍含 --latest: %q", cmd)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cmd, "--latest") {
|
||||
sawLatestGuide = true
|
||||
}
|
||||
}
|
||||
if !sawLatestGuide {
|
||||
t.Fatalf("no --latest-bearing guidance clause in suggestion: %q", suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// extractTrailingDwsCommand 抽子句里以 "dws " 开头的尾部命令片段(到子句末),无则空串。
|
||||
func extractTrailingDwsCommand(clause string) string {
|
||||
idx := strings.LastIndex(clause, "dws ")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return clause[idx:]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
|
||||
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
|
||||
//
|
||||
// 非 Windows 构建面向 POSIX shell,单引号可靠地关闭所有展开,故含元字符的值引用后内联即安全。
|
||||
// Windows 构建见 drive_latest_scope_windows.go —— 两个平台的策略本体都是
|
||||
// shell_quote.go 里的纯函数,可在任意平台被测试直接调用;本文件只做编译期绑定。
|
||||
func driveLatestScopeValue(value string) (string, bool) {
|
||||
return driveLatestPosixScopeValue(value)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package helpers
|
||||
|
||||
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
|
||||
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
|
||||
//
|
||||
// Windows 构建下没有对 cmd.exe 与 PowerShell 同时成立的引用形式(cmd.exe 不认单引号,双引号
|
||||
// 又挡不住 %VAR% 展开),故只内联本身就安全的值;理由与取舍详见
|
||||
// driveLatestWindowsScopeValue 的注释。POSIX 构建见 drive_latest_scope_posix.go。
|
||||
func driveLatestScopeValue(value string) (string, bool) {
|
||||
return driveLatestWindowsScopeValue(value)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageDriveMirrorCoverageGaps 覆盖镜像整批 preflight 引入的
|
||||
// 输出失败和防御式解析分支。测试只调用命名 helper,不依赖真实 Drive 或网络。
|
||||
func TestCrossPlatformCoverageDriveMirrorCoverageGaps(t *testing.T) {
|
||||
t.Run("preflight output failures", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "local")
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`,
|
||||
})
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
pull := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, pull, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDrivePull(pull, nil); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("pull preflight output error = %v", err)
|
||||
}
|
||||
|
||||
push := findDriveSubcommand(t, "push")
|
||||
mustSetFlags(t, push, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDrivePush(push, nil); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("push preflight output error = %v", err)
|
||||
}
|
||||
|
||||
sync := findDriveSubcommand(t, "sync")
|
||||
mustSetFlags(t, sync, map[string]string{"local-folder": root, "remote-folder": "ROOT", "quick": "true"})
|
||||
if err := runDriveSync(sync, nil); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("sync preflight output error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pull remote-only preflight output failure", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"UP"},{"name":"a.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`,
|
||||
})
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
pull := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, pull, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDrivePull(pull, nil); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("pull remote-only preflight output error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run preflight output failures", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
remote := map[string]*remoteFile{"A.txt": {RelPath: "A.txt"}, "a.txt": {RelPath: "a.txt"}}
|
||||
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: failingWriter{}}})
|
||||
if err := printDrivePullDryRun(root, ifExistsSkip, remote, []string{"A.txt", "a.txt"}, true); err == nil {
|
||||
t.Fatal("pull dry-run preflight must propagate output failure")
|
||||
}
|
||||
local := []localPushFile{{RelPath: "a.txt"}}
|
||||
if err := printDrivePushDryRun(ifExistsSkip,
|
||||
map[string]*remoteFile{"A.txt": {RelPath: "A.txt"}}, map[string]string{"": "ROOT"}, nil, local); err == nil {
|
||||
t.Fatal("push dry-run preflight must propagate output failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parser errors", func(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"result":`,
|
||||
`{"result":{"items":[],"hasMore":"bad"}}`,
|
||||
`{"result":{"items":null}}`,
|
||||
`{"result":{"items":"bad"}}`,
|
||||
`{"result":[`,
|
||||
`{"result":[true]}`,
|
||||
`{"result":[{}]}`,
|
||||
`{"result":true}`,
|
||||
} {
|
||||
if _, _, err := parseDriveList(body); err == nil {
|
||||
t.Errorf("parseDriveList(%q) unexpectedly succeeded", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pull dry-run existing-file policies", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
skipPath := filepath.Join(root, "skip.txt")
|
||||
smartPath := filepath.Join(root, "smart.txt")
|
||||
mustWrite(t, skipPath, "skip")
|
||||
mustWrite(t, smartPath, "smart")
|
||||
smartMTime := readFileMTimeMillis(t, smartPath)
|
||||
remote := map[string]*remoteFile{
|
||||
"skip.txt": {RelPath: "skip.txt"},
|
||||
"smart.txt": {RelPath: "smart.txt", ModifiedTime: smartMTime - 1, ModifiedTimeValid: true},
|
||||
}
|
||||
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: io.Discard}})
|
||||
if err := printDrivePullDryRunWithPreflight(root, ifExistsSkip, remote, []string{"skip.txt"}, false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := printDrivePullDryRunWithPreflight(root, ifExistsSmart, remote, []string{"smart.txt"}, false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remote["smart.txt"] = &remoteFile{RelPath: "smart.txt"}
|
||||
if err := printDrivePullDryRunWithPreflight(root, ifExistsSmart, remote, []string{"smart.txt"}, false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pull mkdir and replace errors", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := pullListingCaller("")
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
testseam.Swap(t, &pullMkdirAll, func(string, os.FileMode) error { return errTestDownload })
|
||||
if action, err := pullOneFile(context.Background(), "", &remoteFile{FileID: "F"},
|
||||
filepath.Join(root, "blocked", "x.txt"), ifExistsOverwrite); action != pullActionFailed || err == nil {
|
||||
t.Fatalf("mkdir failure = (%q, %v)", action, err)
|
||||
}
|
||||
|
||||
targetDir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(targetDir, "target.txt"), "local")
|
||||
testseam.Swap(t, &pullMkdirAll, os.MkdirAll)
|
||||
testseam.Swap(t, &pullRename, func(*os.Root, string, string) error { return errTestDownload })
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("remote"), 0o644)
|
||||
})
|
||||
if action, err := pullOneFile(context.Background(), "", &remoteFile{FileID: "F"},
|
||||
filepath.Join(targetDir, "target.txt"), ifExistsOverwrite); action != pullActionFailed || err == nil {
|
||||
t.Fatalf("replace failure = (%q, %v)", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sync pull defensive failures", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
res := &driveSyncResult{}
|
||||
syncPullFile(res, context.Background(), "", &remoteFile{FileID: "F"}, root, "sub/a.txt", syncDirectionPull)
|
||||
if res.Summary.Failed != 1 {
|
||||
t.Fatalf("escape failed = %d", res.Summary.Failed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pull remote file rechecks symlink escape", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
action, err := pullRemoteFile(context.Background(), "", &remoteFile{FileID: "F"}, root,
|
||||
"sub/a.txt", ifExistsOverwrite)
|
||||
if action != pullActionFailed || err == nil {
|
||||
t.Fatalf("pull remote TOCTOU guard = (%q, %v)", action, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func runDriveMirrorCommand(t *testing.T, caller *driveScriptCaller, dryRun bool, args ...string) ([]byte, error) {
|
||||
t.Helper()
|
||||
caller.dryRun = dryRun
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.AddCommand(newDriveCommand())
|
||||
full := append(append([]string{}, args...), "--yes")
|
||||
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...))
|
||||
root.SetArgs(append([]string{"drive"}, full...))
|
||||
err := root.Execute()
|
||||
return append([]byte(nil), out.Bytes()...), err
|
||||
}
|
||||
|
||||
func captureMirrorHTTP(t *testing.T) (getCalls, putCalls *int) {
|
||||
t.Helper()
|
||||
gets, puts := 0, 0
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
gets++
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
puts++
|
||||
return nil
|
||||
})
|
||||
return &gets, &puts
|
||||
}
|
||||
|
||||
func assertMirrorPreflightNoWrites(t *testing.T, caller *driveScriptCaller, getCalls, putCalls int) {
|
||||
t.Helper()
|
||||
for _, tool := range []string{"create_folder", "download_file", "get_upload_info", "commit_upload"} {
|
||||
if calls := caller.callsFor(tool); len(calls) != 0 {
|
||||
t.Errorf("mirror preflight must not call %s: %v", tool, calls)
|
||||
}
|
||||
}
|
||||
if getCalls != 0 || putCalls != 0 {
|
||||
t.Errorf("mirror preflight must not transfer content: GET=%d PUT=%d", getCalls, putCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_nonRegularTargetFailsBeforeDownload(t *testing.T) {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
name := "execute"
|
||||
if dryRun {
|
||||
name = "dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "blocked.txt"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"blocked.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`,
|
||||
})
|
||||
gets, puts := captureMirrorHTTP(t)
|
||||
out, err := runDriveMirrorCommand(t, caller, dryRun, "pull", "--local-folder", root, "--remote-folder", "ROOT")
|
||||
if dryRun {
|
||||
if err != nil {
|
||||
t.Fatalf("pull dry-run: %v", err)
|
||||
}
|
||||
var got drivePullDryRunResult
|
||||
if jsonErr := json.Unmarshal(out, &got); jsonErr != nil {
|
||||
t.Fatalf("pull dry-run output is not JSON: %v\n%s", jsonErr, out)
|
||||
}
|
||||
if got.Plan.Summary.Failed != 1 || len(got.Plan.Items) != 1 || !strings.Contains(got.Plan.Items[0].Error, "不是常规文件") {
|
||||
t.Fatalf("pull dry-run plan = %+v, want one non-regular target failure", got.Plan)
|
||||
}
|
||||
} else {
|
||||
var failure *drivePartialFailure
|
||||
if !errors.As(err, &failure) || failure.failed != 1 {
|
||||
t.Fatalf("pull error = %v, want one failed item", err)
|
||||
}
|
||||
}
|
||||
assertMirrorPreflightNoWrites(t, caller, *gets, *puts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_nonRegularTargetAbortsWholeBatchBeforeDownload(t *testing.T) {
|
||||
for _, command := range []string{"pull", "sync"} {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
name := command + "/execute"
|
||||
if dryRun {
|
||||
name = command + "/dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "z-blocked.txt"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"a-first.txt","type":"file","fileId":"FIRST"},{"name":"z-blocked.txt","type":"file","fileId":"BLOCKED"}],"nextToken":""}}`,
|
||||
})
|
||||
gets, puts := captureMirrorHTTP(t)
|
||||
args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"}
|
||||
if command == "sync" {
|
||||
args = append(args, "--quick")
|
||||
}
|
||||
out, err := runDriveMirrorCommand(t, caller, dryRun, args...)
|
||||
if dryRun {
|
||||
if err != nil {
|
||||
t.Fatalf("%s dry-run: %v", command, err)
|
||||
}
|
||||
if command == "pull" {
|
||||
var got drivePullDryRunResult
|
||||
if jsonErr := json.Unmarshal(out, &got); jsonErr != nil || got.Plan.Summary.Failed != 2 {
|
||||
t.Fatalf("pull dry-run preflight = %+v, json error %v", got, jsonErr)
|
||||
}
|
||||
} else {
|
||||
assertMirrorDryRunFailureJSON(t, command, out)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatalf("%s must abort the whole batch", command)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(root, "a-first.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("%s wrote an earlier file before discovering the conflict: %v", command, statErr)
|
||||
}
|
||||
assertMirrorPreflightNoWrites(t, caller, *gets, *puts)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_remoteEquivalentTreeFailsBeforeDownload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listings map[string]string
|
||||
wantFail int
|
||||
}{
|
||||
{
|
||||
name: "equivalent files",
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"UP"},{"name":"a.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`,
|
||||
},
|
||||
wantFail: 2,
|
||||
},
|
||||
{
|
||||
name: "equivalent folder prefixes",
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"Folder","type":"folder","fileId":"UP_DIR"},{"name":"folder","type":"folder","fileId":"LOW_DIR"}],"nextToken":""}}`,
|
||||
"UP_DIR": `{"result":{"items":[{"name":"up.txt","type":"file","fileId":"UP"}],"nextToken":""}}`,
|
||||
"LOW_DIR": `{"result":{"items":[{"name":"low.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`,
|
||||
},
|
||||
wantFail: 2,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
name := tt.name + "/execute"
|
||||
if dryRun {
|
||||
name = tt.name + "/dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := syncCaller(tt.listings)
|
||||
gets, puts := captureMirrorHTTP(t)
|
||||
out, err := runDriveMirrorCommand(t, caller, dryRun, "pull", "--local-folder", root, "--remote-folder", "ROOT")
|
||||
if dryRun {
|
||||
if err != nil {
|
||||
t.Fatalf("pull dry-run: %v", err)
|
||||
}
|
||||
var got drivePullDryRunResult
|
||||
if jsonErr := json.Unmarshal(out, &got); jsonErr != nil {
|
||||
t.Fatalf("pull dry-run output is not JSON: %v\n%s", jsonErr, out)
|
||||
}
|
||||
if got.Plan.Summary.Failed != tt.wantFail || got.Plan.Summary.Downloaded != 0 {
|
||||
t.Fatalf("pull dry-run plan = %+v", got.Plan)
|
||||
}
|
||||
} else {
|
||||
var failure *drivePartialFailure
|
||||
if !errors.As(err, &failure) || failure.failed != tt.wantFail {
|
||||
t.Fatalf("pull error = %v, want %d failed items", err, tt.wantFail)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(out), "等价路径") {
|
||||
t.Fatalf("pull output must explain equivalent path ambiguity: %s", out)
|
||||
}
|
||||
assertMirrorPreflightNoWrites(t, caller, *gets, *puts)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveMirror_unsafeLocalNamesFailClosed(t *testing.T) {
|
||||
if os.PathSeparator == '\\' {
|
||||
t.Skip("current platform cannot create a local name containing a backslash")
|
||||
}
|
||||
for _, command := range []string{"push", "sync"} {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
name := command + "/execute"
|
||||
if dryRun {
|
||||
name = command + "/dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, `unsafe\name.txt`), "local")
|
||||
caller := syncCaller(nil)
|
||||
gets, puts := captureMirrorHTTP(t)
|
||||
args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"}
|
||||
if command == "sync" {
|
||||
args = append(args, "--quick")
|
||||
}
|
||||
out, err := runDriveMirrorCommand(t, caller, dryRun, args...)
|
||||
if dryRun {
|
||||
if err != nil {
|
||||
t.Fatalf("%s dry-run: %v", command, err)
|
||||
}
|
||||
assertMirrorDryRunFailureJSON(t, command, out)
|
||||
} else if err == nil {
|
||||
t.Fatalf("%s must fail an unsafe local name", command)
|
||||
}
|
||||
if !strings.Contains(string(out), "无法安全映射到远端") {
|
||||
t.Fatalf("%s output must explain unsafe local name: %s", command, out)
|
||||
}
|
||||
assertMirrorPreflightNoWrites(t, caller, *gets, *puts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NUL 不能由真实文件系统创建,直接覆盖契约 helper,避免该分支成为死角。
|
||||
preflight := buildMirrorPreflight(nil, []localPushFile{{RelPath: "bad\x00name.txt"}}, nil, map[string]string{"": "ROOT"})
|
||||
if !strings.Contains(preflight["bad\x00name.txt"], "无法安全映射到远端") {
|
||||
t.Fatalf("NUL name preflight = %v", preflight)
|
||||
}
|
||||
if err := rejectNonRegularLocalTarget("bad\x00target"); err == nil || !strings.Contains(err.Error(), "检查本地目标失败") {
|
||||
t.Fatalf("invalid local target error = %v", err)
|
||||
}
|
||||
|
||||
localCollision := buildMirrorPreflight(nil, []localPushFile{{RelPath: "A.txt"}, {RelPath: "a.txt"}}, nil, map[string]string{"": "ROOT"})
|
||||
for _, rel := range []string{"A.txt", "a.txt"} {
|
||||
if !strings.Contains(localCollision[rel], "等价路径") {
|
||||
t.Fatalf("local equivalent collision %q = %q", rel, localCollision[rel])
|
||||
}
|
||||
}
|
||||
prefixCollision := buildMirrorPreflight(nil, []localPushFile{{RelPath: "A/x.txt"}},
|
||||
map[string]*remoteFile{"a/y.txt": {RelPath: "a/y.txt"}}, map[string]string{"": "ROOT"})
|
||||
for _, rel := range []string{"A", "a"} {
|
||||
if !strings.Contains(prefixCollision[rel], "等价路径") {
|
||||
t.Fatalf("equivalent ancestor collision %q = %q", rel, prefixCollision[rel])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveMirror_crossSideEquivalentPathsFailBeforeWrites(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(*testing.T, string)
|
||||
listings map[string]string
|
||||
}{
|
||||
{
|
||||
name: "case-equivalent files",
|
||||
prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "a.txt"), "local") },
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "case-equivalent folder prefixes",
|
||||
prepare: func(t *testing.T, root string) {
|
||||
mustWrite(t, filepath.Join(root, "Folder", "local.txt"), "local")
|
||||
},
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"folder","type":"folder","fileId":"REMOTE_DIR"}],"nextToken":""}}`,
|
||||
"REMOTE_DIR": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"REMOTE_FILE"}],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unicode-equivalent files",
|
||||
prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "caf\u00e9.txt"), "local") },
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"cafe\u0301.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "equivalent path type ambiguity",
|
||||
prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "item"), "local") },
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"ITEM","type":"folder","fileId":"REMOTE_DIR"}],"nextToken":""}}`,
|
||||
"REMOTE_DIR": `{"result":{"items":[],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote-only equivalent files",
|
||||
prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "safe-local.txt"), "local") },
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"A.txt","type":"file","fileId":"UP"},{"name":"a.txt","type":"file","fileId":"LOW"}],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remote-only equivalent folders",
|
||||
prepare: func(t *testing.T, root string) { mustWrite(t, filepath.Join(root, "safe-local.txt"), "local") },
|
||||
listings: map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"Folder","type":"folder","fileId":"UP_DIR"},{"name":"folder","type":"folder","fileId":"LOW_DIR"}],"nextToken":""}}`,
|
||||
"UP_DIR": `{"result":{"items":[],"nextToken":""}}`,
|
||||
"LOW_DIR": `{"result":{"items":[],"nextToken":""}}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, command := range []string{"push", "sync"} {
|
||||
for _, dryRun := range []bool{false, true} {
|
||||
name := tt.name + "/" + command + "/execute"
|
||||
if dryRun {
|
||||
name = tt.name + "/" + command + "/dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tt.prepare(t, root)
|
||||
caller := syncCaller(tt.listings)
|
||||
gets, puts := captureMirrorHTTP(t)
|
||||
args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"}
|
||||
if command == "sync" {
|
||||
args = append(args, "--quick")
|
||||
}
|
||||
out, err := runDriveMirrorCommand(t, caller, dryRun, args...)
|
||||
if dryRun {
|
||||
if err != nil {
|
||||
t.Fatalf("%s dry-run: %v", command, err)
|
||||
}
|
||||
assertMirrorDryRunFailureJSON(t, command, out)
|
||||
} else if err == nil {
|
||||
t.Fatalf("%s must reject an equivalent cross-side path", command)
|
||||
}
|
||||
if !strings.Contains(string(out), "等价路径") {
|
||||
t.Fatalf("%s output must explain equivalent path ambiguity: %s", command, out)
|
||||
}
|
||||
assertMirrorPreflightNoWrites(t, caller, *gets, *puts)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMirrorDryRunFailureJSON(t *testing.T, command string, out []byte) {
|
||||
t.Helper()
|
||||
switch command {
|
||||
case "push":
|
||||
var got drivePushDryRunResult
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("push dry-run output is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !got.DryRun || got.Executed || got.Operation != "drive push" || got.Plan.Summary.Failed == 0 || !got.Plan.Summary.Aborted {
|
||||
t.Fatalf("push dry-run result = %+v", got)
|
||||
}
|
||||
case "sync":
|
||||
var got driveSyncDryRunResult
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("sync dry-run output is not standalone JSON: %v\n%s", err, out)
|
||||
}
|
||||
if !got.DryRun || got.Executed || got.PreviewKind != "plan" || got.Operation != "drive sync" || got.Plan.Summary.Failed == 0 {
|
||||
t.Fatalf("sync dry-run result = %+v", got)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unsupported command %q", command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// Windows 会用已打开的目录句柄锁住目录,把「固定后目录被移走/换成软链」这类
|
||||
// TOCTOU 在该平台变成物理不可达:os.Rename 直接 ERROR_SHARING_VIOLATION。
|
||||
// 但被测的 fail-closed 分支必须在所有平台都覆盖,所以这里提供与真实替换等价的
|
||||
// 身份注入:pinnedPullRoot.verify() 与 verifyParent() 都只通过 pullPathStat /
|
||||
// pullRootLstat 读取当前身份,让这两个 seam 指向另一个目录即可命中同一分支。
|
||||
|
||||
// forcePinnedFallbackForTest 打开后,「移走固定目录」的复现手法一律走身份注入降级。
|
||||
// 默认 false:Unix 上用真实移动,验证更强。只有 Windows 才会真的走降级分支,所以
|
||||
// 下面的注入回归测试会打开它,让那条路径在任何平台都能被验证。
|
||||
var forcePinnedFallbackForTest = false
|
||||
|
||||
// swapPinnedRootIdentity 让 absDir 的根身份读数指向另一个目录,使随后的
|
||||
// pinnedPullRoot.verify() 判定「本地根目录在同步期间被替换」。
|
||||
func swapPinnedRootIdentity(t *testing.T, absDir string) {
|
||||
t.Helper()
|
||||
decoy, err := os.Stat(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Clean(absDir)
|
||||
testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) {
|
||||
if filepath.Clean(path) == want {
|
||||
return decoy, nil
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
}
|
||||
|
||||
// 降级注入只有 Windows 会真的走到(Unix 的 rename 成功)。这里强制打开开关,
|
||||
// 确保注入手段本身在任何平台都可回归:不移动目录,但固定根的校验必须随之失败,
|
||||
// 且原有目录树不得被动过。
|
||||
func TestCrossPlatformCoveragePinnedRootFallbackInjectionFailsVerification(t *testing.T) {
|
||||
testseam.Swap(t, &forcePinnedFallbackForTest, true)
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "pinned-original")
|
||||
root := newPinnedPullRootForCoverage(t, dir)
|
||||
if err := root.verify(); err != nil {
|
||||
t.Fatalf("baseline verify: %v", err)
|
||||
}
|
||||
|
||||
if moved := replacePinnedMirrorRoot(t, dir, filepath.Join(t.TempDir(), "moved")); moved {
|
||||
t.Fatal("降级路径不应真的移动目录")
|
||||
}
|
||||
if err := root.verify(); err == nil || !strings.Contains(err.Error(), "本地根目录在同步期间被替换") {
|
||||
t.Fatalf("身份注入后校验必须失败,got %v", err)
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(dir, "a.txt")); err != nil || string(b) != "pinned-original" {
|
||||
t.Fatalf("降级必须保持原目录树不变: %q err=%v", string(b), err)
|
||||
}
|
||||
}
|
||||
|
||||
// swapPinnedParentIdentity 让固定根下名为 rel 的父目录身份读数指向另一个目录,
|
||||
// 使随后的 verifyParent() 判定「本地目标目录在下载期间被替换」。
|
||||
func swapPinnedParentIdentity(t *testing.T, rel string) {
|
||||
t.Helper()
|
||||
decoy, err := os.Lstat(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &pullRootLstat, func(parent *os.Root, name string) (os.FileInfo, error) {
|
||||
if filepath.ToSlash(filepath.Clean(name)) == rel {
|
||||
return decoy, nil
|
||||
}
|
||||
return parent.Lstat(name)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,974 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// ==========================================================
|
||||
// drive pull — 把钉盘文件夹镜像到本地(Drive → 本地)
|
||||
// ==========================================================
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws drive pull — 把钉盘文件夹单向、文件级镜像到本地(Drive → 本地)
|
||||
//
|
||||
// 递归列出 --remote-folder 指向的钉盘文件夹下所有 type=FILE 的文件,逐一下载到
|
||||
// --local-folder 对应的相对路径。已存在的本地文件按 --if-exists 决定
|
||||
// overwrite / smart / skip。结构化 summary + items 始终打印到 stdout;
|
||||
// summary.failed > 0 时额外以非零退出码退出。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// --if-exists 的三种策略。
|
||||
const (
|
||||
ifExistsOverwrite = "overwrite" // 总是下载覆盖(Drive 为权威源)
|
||||
ifExistsSmart = "smart" // 推荐增量:本地 mtime 已 ≥ 远端 modified_time 则跳过
|
||||
ifExistsSkip = "skip" // 默认:本地已存在则保持不动
|
||||
)
|
||||
|
||||
// pull 下载路径使用 os.Root 固定根目录和目标父目录。以下 seam 只用于
|
||||
// 确定性覆盖文件系统与传输失败分支;测试必须通过 testseam.Swap 替换。
|
||||
var (
|
||||
pullMkdirAll = os.MkdirAll
|
||||
pullOpenRoot = os.OpenRoot
|
||||
pullRelPath = filepath.Rel
|
||||
pullPathStat = os.Stat
|
||||
pullPathLstat = os.Lstat
|
||||
pullTargetLstat = os.Lstat
|
||||
pullRootMkdir = func(root *os.Root, name string, mode os.FileMode) error {
|
||||
return root.Mkdir(name, mode)
|
||||
}
|
||||
pullOpenParentRoot = func(root *os.Root, name string) (*os.Root, error) {
|
||||
return root.OpenRoot(name)
|
||||
}
|
||||
pullRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) }
|
||||
pullRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) }
|
||||
pullCreateTemp = createPinnedPullTemp
|
||||
pullTempName = uuid.NewString
|
||||
pullSyncTemp = func(file *os.File) error { return file.Sync() }
|
||||
pullCloseTemp = func(file *os.File) error { return file.Close() }
|
||||
pullRename = func(root *os.Root, oldName, newName string) error {
|
||||
return root.Rename(oldName, newName)
|
||||
}
|
||||
pullLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
|
||||
pullRemove = func(root *os.Root, name string) error { return root.Remove(name) }
|
||||
pullDownloadFile = defaultPullDownloadFile
|
||||
pullHTTPDo = func(request *http.Request) (*http.Response, error) {
|
||||
return (&http.Client{Timeout: 10 * time.Minute}).Do(request)
|
||||
}
|
||||
)
|
||||
|
||||
// pull 动作分类。
|
||||
const (
|
||||
pullActionDownloaded = "downloaded"
|
||||
pullActionSkipped = "skipped"
|
||||
pullActionFailed = "failed"
|
||||
)
|
||||
|
||||
// drivePullItem 是输出 items[] 中每个文件的明细。
|
||||
type drivePullItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// drivePullSummary 是各动作的计数汇总。
|
||||
type drivePullSummary struct {
|
||||
Downloaded int `json:"downloaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// drivePullResult 是 pull 命令的输出 schema。
|
||||
type drivePullResult struct {
|
||||
Summary drivePullSummary `json:"summary"`
|
||||
Items []drivePullItem `json:"items"`
|
||||
}
|
||||
|
||||
type drivePullDryRunResult struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Executed bool `json:"executed"`
|
||||
PreviewKind string `json:"preview_kind"`
|
||||
Operation string `json:"operation"`
|
||||
IfExists string `json:"if_exists"`
|
||||
Plan drivePullResult `json:"plan"`
|
||||
}
|
||||
|
||||
// drivePartialFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout,
|
||||
// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明(与 push/sync 一致)。
|
||||
type drivePartialFailure struct{ failed int }
|
||||
|
||||
func (e *drivePartialFailure) Error() string {
|
||||
return fmt.Sprintf("drive pull: %d file(s) failed", e.failed)
|
||||
}
|
||||
func (e *drivePartialFailure) RawStderr() string { return e.Error() }
|
||||
func (e *drivePartialFailure) ExitCode() int { return 1 }
|
||||
|
||||
// pathCollisionKey 把本地目标路径归一化成「目标文件系统下的等价键」,用于探测多个
|
||||
// 远端条目是否会落到同一个本地文件。caseInsensitive 为真时(Windows / 默认 macOS)
|
||||
// 折叠大小写并做 Unicode NFC 规范化,从而把 A.txt/a.txt、NFC/NFD 记法视为同一目标;
|
||||
// 大小写敏感文件系统(如 Linux ext4)则按精确路径区分,避免误判合法的异名文件。
|
||||
func pathCollisionKey(target string, caseInsensitive bool) string {
|
||||
p := filepath.Clean(target)
|
||||
if caseInsensitive {
|
||||
p = norm.NFC.String(strings.ToLower(p))
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// isCaseInsensitiveFS 是大小写探测的注入点(与 httpGetFile / httpPutFile 同样的 seam):
|
||||
// 生产路径始终是 detectCaseInsensitiveFS,测试可替换它,以便在大小写敏感的 CI 文件系统上
|
||||
// 也能走到「等价路径冲突」分支。
|
||||
var isCaseInsensitiveFS = detectCaseInsensitiveFS
|
||||
|
||||
// caseProbePattern 是探针文件名模板。抽成变量只为可测:纯数字模板的大写与原名相同,
|
||||
// 可覆盖「名称无大小写差异、无法据此判定」的回退分支。
|
||||
var caseProbePattern = "dws-caseprobe-*"
|
||||
|
||||
// detectCaseInsensitiveFS 探测 dir 所在文件系统是否大小写不敏感:在 dir 下创建一个随机
|
||||
// 小写名探针文件,再用其大写名 stat;命中同一文件即为不敏感。dir 必须已存在。
|
||||
// 探测失败时回退到平台默认(Windows / macOS 视为不敏感,其它敏感)。
|
||||
func detectCaseInsensitiveFS(dir string) bool {
|
||||
platformDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
||||
f, err := os.CreateTemp(dir, caseProbePattern)
|
||||
if err != nil {
|
||||
return platformDefault
|
||||
}
|
||||
name := f.Name()
|
||||
_ = f.Close()
|
||||
defer os.Remove(name)
|
||||
base := filepath.Base(name)
|
||||
upper := strings.ToUpper(base)
|
||||
if upper == base {
|
||||
return platformDefault // 名称无大小写差异,无法据此判定
|
||||
}
|
||||
_, statErr := os.Stat(filepath.Join(dir, upper))
|
||||
return statErr == nil
|
||||
}
|
||||
|
||||
// detectTargetCollisions 按 caseInsensitive 指定的等价规则,找出会映射到同一本地目标
|
||||
// 的多个远端 rel_path,返回被判定冲突的 rel_path 集合(其中每个都不应落盘)。
|
||||
func detectTargetCollisions(absDir string, rels []string, caseInsensitive bool) map[string]bool {
|
||||
groups := make(map[string][]string, len(rels))
|
||||
for _, rel := range rels {
|
||||
target := filepath.Join(absDir, filepath.FromSlash(rel))
|
||||
key := pathCollisionKey(target, caseInsensitive)
|
||||
groups[key] = append(groups[key], rel)
|
||||
}
|
||||
collided := make(map[string]bool)
|
||||
for _, g := range groups {
|
||||
if len(g) > 1 {
|
||||
for _, r := range g {
|
||||
collided[r] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return collided
|
||||
}
|
||||
|
||||
func runDrivePull(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil {
|
||||
return err
|
||||
}
|
||||
localDir := mustGetFlag(cmd, "local-folder")
|
||||
remoteDirID := mustGetFlag(cmd, "remote-folder")
|
||||
// space-id 可选:不传则由 fetchRemoteDriveTree 使用「我的文件」对应的空间。
|
||||
spaceID := mustGetFlag(cmd, "space-id")
|
||||
|
||||
ifExists, _ := cmd.Flags().GetString("if-exists")
|
||||
if ifExists == "" {
|
||||
// 安全默认:不自动覆盖本地既有文件。
|
||||
ifExists = ifExistsSkip
|
||||
}
|
||||
switch ifExists {
|
||||
case ifExistsOverwrite, ifExistsSmart, ifExistsSkip:
|
||||
default:
|
||||
return fmt.Errorf("--if-exists 取值非法: %s(可选 overwrite|smart|skip)", ifExists)
|
||||
}
|
||||
|
||||
absDir, err := validateLocalDirAbs(localDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 在任何远端读取之前固定本地根计划:既有根立即持有 os.Root;不存在的根只固定
|
||||
// 最近既存祖先并记录尚缺的逐层后缀。远端清单不完整或预检失败时不得创建本地根。
|
||||
rootPlan, err := planPinnedPullRoot(absDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rootPlan.close()
|
||||
|
||||
ctx := cmd.Context()
|
||||
// 复用 status 的远端遍历:按 parentId 递归拿到所有 type=FILE 的文件(key 为 rel_path)。
|
||||
remote, err := fetchRemoteDriveTree(ctx, spaceID, remoteDirID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 稳定顺序输出(rel_path 升序)。
|
||||
relPaths := make([]string, 0, len(remote))
|
||||
for rel := range remote {
|
||||
relPaths = append(relPaths, rel)
|
||||
}
|
||||
sort.Strings(relPaths)
|
||||
preflight, localInfo, err := buildDrivePullPreflightFromPlan(rootPlan, remote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return printDrivePullDryRunWithLocalInfo(ifExists, remote, relPaths, preflight, localInfo)
|
||||
}
|
||||
// 镜像契约独立于当前主机文件系统:远端 A/a 或 NFC/NFD 异写(包括目录
|
||||
// 前缀)在任何落盘前整批拒绝,避免同一远端树在不同平台得到不同镜像。
|
||||
if len(preflight) > 0 {
|
||||
res := drivePullPreflightFailureResult(relPaths, preflight)
|
||||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||||
return perr
|
||||
}
|
||||
return &drivePartialFailure{failed: res.Summary.Failed}
|
||||
}
|
||||
pinnedRoot, err := rootPlan.materialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pinnedRoot.close()
|
||||
|
||||
res := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
|
||||
for _, rel := range relPaths {
|
||||
rf := remote[rel]
|
||||
// preflight 与真正下载之间仍需二次解析,挡住祖先目录被并发替换为软链的
|
||||
// TOCTOU 逃逸;pullRemoteFile 将该检查收口在写入入口前。
|
||||
action, perr := pullRemoteFilePinned(ctx, spaceID, rf, pinnedRoot, rel, ifExists)
|
||||
item := drivePullItem{RelPath: rel, Action: action}
|
||||
switch action {
|
||||
case pullActionDownloaded:
|
||||
res.Summary.Downloaded++
|
||||
case pullActionSkipped:
|
||||
res.Summary.Skipped++
|
||||
case pullActionFailed:
|
||||
res.Summary.Failed++
|
||||
if perr != nil {
|
||||
item.Error = perr.Error()
|
||||
}
|
||||
}
|
||||
res.Items = append(res.Items, item)
|
||||
}
|
||||
|
||||
// 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。
|
||||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||||
return perr
|
||||
}
|
||||
if res.Summary.Failed > 0 {
|
||||
return &drivePartialFailure{failed: res.Summary.Failed}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printDrivePullDryRun(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool) error {
|
||||
return printDrivePullDryRunWithPreflight(absDir, ifExists, remote, relPaths, caseInsensitive,
|
||||
buildDrivePullPreflight(absDir, remote))
|
||||
}
|
||||
|
||||
func printDrivePullDryRunWithPreflight(absDir, ifExists string, remote map[string]*remoteFile, relPaths []string, caseInsensitive bool, preflight map[string]string) error {
|
||||
localInfo := make(map[string]os.FileInfo, len(relPaths))
|
||||
for _, rel := range relPaths {
|
||||
localPath, err := resolveLocalTarget(absDir, rel)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info, statErr := os.Stat(localPath); statErr == nil && info.Mode().IsRegular() {
|
||||
localInfo[rel] = info
|
||||
}
|
||||
}
|
||||
_ = caseInsensitive // 保留 helper 的平台预览参数兼容性。
|
||||
return printDrivePullDryRunWithLocalInfo(ifExists, remote, relPaths, preflight, localInfo)
|
||||
}
|
||||
|
||||
func printDrivePullDryRunWithLocalInfo(ifExists string, remote map[string]*remoteFile, relPaths []string, preflight map[string]string, localInfo map[string]os.FileInfo) error {
|
||||
plan := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
|
||||
if len(preflight) > 0 {
|
||||
plan = drivePullPreflightFailureResult(relPaths, preflight)
|
||||
return deps.Out.PrintJSON(drivePullDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive pull",
|
||||
IfExists: ifExists, Plan: plan,
|
||||
})
|
||||
}
|
||||
for _, rel := range relPaths {
|
||||
action := pullActionDownloaded
|
||||
if fi := localInfo[rel]; fi != nil {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
action = pullActionSkipped
|
||||
case ifExistsSmart:
|
||||
rf := remote[rel]
|
||||
if rf.ModifiedTimeValid && fi.ModTime().UnixMilli() >= rf.ModifiedTime {
|
||||
action = pullActionSkipped
|
||||
}
|
||||
}
|
||||
}
|
||||
if action == pullActionSkipped {
|
||||
plan.Summary.Skipped++
|
||||
} else {
|
||||
plan.Summary.Downloaded++
|
||||
}
|
||||
plan.Items = append(plan.Items, drivePullItem{RelPath: rel, Action: action})
|
||||
}
|
||||
return deps.Out.PrintJSON(drivePullDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive pull",
|
||||
IfExists: ifExists, Plan: plan,
|
||||
})
|
||||
}
|
||||
|
||||
// buildDrivePullPreflight 在创建本地根目录、探测文件系统或下载任一文件之前,
|
||||
// 一次性验证完整远端树及每个本地落盘目标。任一目标是目录、符号链接、设备文件,
|
||||
// 或经既有符号链接逃逸时,整批 pull 都必须零写入中止。
|
||||
func buildDrivePullPreflight(absDir string, remote map[string]*remoteFile) map[string]string {
|
||||
failures := buildMirrorPreflight(nil, nil, remote, nil)
|
||||
addLocalTargetPreflightFailures(absDir, remote, failures)
|
||||
return failures
|
||||
}
|
||||
|
||||
// buildDrivePullPreflightFromPlan 只从 list_files 前已固定的根计划读取本地状态。
|
||||
// existing 根不再按 absDir 重开路径;missing 根在 ancestor Root 下仍必须完整缺失。
|
||||
func buildDrivePullPreflightFromPlan(plan *pinnedPullRootPlan, remote map[string]*remoteFile) (map[string]string, map[string]os.FileInfo, error) {
|
||||
failures := buildMirrorPreflight(nil, nil, remote, nil)
|
||||
localInfo := make(map[string]os.FileInfo, len(remote))
|
||||
if err := plan.verify(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if plan.existing != nil {
|
||||
addPinnedLocalTargetPreflightFailures(plan.existing, remote, failures)
|
||||
for rel := range remote {
|
||||
if mirrorPreflightFailureForRel(rel, failures) != "" {
|
||||
continue
|
||||
}
|
||||
info, err := pullRootLstat(plan.existing.root, filepath.FromSlash(rel))
|
||||
if err == nil && info.Mode().IsRegular() {
|
||||
localInfo[rel] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := plan.verify(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return failures, localInfo, nil
|
||||
}
|
||||
|
||||
func addLocalTargetPreflightFailures(absDir string, remote map[string]*remoteFile, failures map[string]string) {
|
||||
for rel := range remote {
|
||||
// 已被名称/类型冲突根屏蔽的后代不重复计错;一个冲突根只报告一次。
|
||||
if mirrorPreflightFailureForRel(rel, failures) != "" {
|
||||
continue
|
||||
}
|
||||
localPath, err := resolveLocalTarget(absDir, rel)
|
||||
if err == nil {
|
||||
err = rejectNonRegularLocalTarget(localPath)
|
||||
}
|
||||
if err != nil {
|
||||
failures[rel] = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drivePullPreflightFailureResult(relPaths []string, preflight map[string]string) drivePullResult {
|
||||
res := drivePullResult{Items: make([]drivePullItem, 0, len(relPaths))}
|
||||
for _, rel := range relPaths {
|
||||
msg := mirrorPreflightFailureForRel(rel, preflight)
|
||||
if msg == "" {
|
||||
msg = "另一镜像条目未通过预检,整批拉取已中止"
|
||||
}
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, drivePullItem{RelPath: rel, Action: pullActionFailed, Error: msg})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func mirrorPreflightFailureForRel(rel string, preflight map[string]string) string {
|
||||
for rel != "" {
|
||||
if msg := preflight[rel]; msg != "" {
|
||||
return msg
|
||||
}
|
||||
rel, _ = splitRel(rel)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pullRemoteFile(ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, ifExists string) (string, error) {
|
||||
return pullOneFileAtRoot(ctx, spaceID, rf, absDir, rel, ifExists)
|
||||
}
|
||||
|
||||
func pullRemoteFilePinned(ctx context.Context, spaceID string, rf *remoteFile, root *pinnedPullRoot, rel, ifExists string) (string, error) {
|
||||
target, err := root.openTarget(rel)
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
defer target.close()
|
||||
return pullOneFilePinned(ctx, spaceID, rf, target, ifExists)
|
||||
}
|
||||
|
||||
// pullOneFile 处理单个远端文件:按 --if-exists 决定是否跳过,否则下载到 localPath。
|
||||
// 返回动作分类(downloaded / skipped / failed)及失败时的 error。
|
||||
func pullOneFile(ctx context.Context, spaceID string, rf *remoteFile, localPath, ifExists string) (string, error) {
|
||||
return pullOneFileAtRoot(ctx, spaceID, rf, filepath.Dir(localPath), filepath.Base(localPath), ifExists)
|
||||
}
|
||||
|
||||
func pullOneFileAtRoot(ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, ifExists string) (string, error) {
|
||||
root, err := openPinnedPullRoot(absDir)
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
defer root.close()
|
||||
target, err := root.openTarget(rel)
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
defer target.close()
|
||||
return pullOneFilePinned(ctx, spaceID, rf, target, ifExists)
|
||||
}
|
||||
|
||||
func pullOneFilePinned(ctx context.Context, spaceID string, rf *remoteFile, target *pinnedPullTarget, ifExists string) (string, error) {
|
||||
// 镜像文件的目标若已存在,必须是常规文件。目录、设备、FIFO 等都不能交给
|
||||
// download_file / 临时文件写入流程,否则 dry-run 与执行结论会分叉,且目录目标
|
||||
// 会在发出远端读请求后才失败。
|
||||
initialInfo, err := target.regularTargetInfo()
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
// 本地已存在常规文件时,按策略判断是否跳过下载。
|
||||
if initialInfo != nil {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
return pullActionSkipped, nil
|
||||
case ifExistsSmart:
|
||||
// 远端时间可信且本地 mtime 已 ≥ 远端 → 视为已对齐,跳过;
|
||||
// 时间缺失/非法时不盲跳,退回继续下载。
|
||||
if rf.ModifiedTimeValid && initialInfo.ModTime().UnixMilli() >= rf.ModifiedTime {
|
||||
return pullActionSkipped, nil
|
||||
}
|
||||
case ifExistsOverwrite:
|
||||
// 总是下载覆盖。
|
||||
}
|
||||
}
|
||||
|
||||
// download_file → 拿到带签名的下载 URL 与请求头,再 HTTP GET 落盘。
|
||||
args := map[string]any{"fileId": rf.FileID}
|
||||
if spaceID != "" {
|
||||
args["spaceId"] = spaceID
|
||||
}
|
||||
text, err := callMCPToolReturnText(ctx, "download_file", args)
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
resourceURL, headers, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
|
||||
// download_file 可执行任意时长的远端读。在任何 HTTP GET 或临时文件写入前,
|
||||
// 必须确认目标父目录仍是最初固定在本地根下的同一目录。
|
||||
if err := target.verifyParent(); err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
|
||||
// 临时文件通过已固定的 os.Root 直接打开,下载直接写已打开的句柄;
|
||||
// 生产路径不会再按可被换链的绝对路径重新打开临时文件。
|
||||
tmp, tmpName, err := pullCreateTemp(target.parentRoot)
|
||||
if err != nil {
|
||||
return pullActionFailed, fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
// 除非成功 rename,否则始终清理临时文件,不留半成品。
|
||||
committed := false
|
||||
tmpInfo, err := tmp.Stat()
|
||||
if err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = target.parentRoot.Remove(tmpName)
|
||||
return pullActionFailed, fmt.Errorf("读取临时文件身份失败: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
if !committed {
|
||||
_ = target.parentRoot.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := pullDownloadFile(ctx, resourceURL, headers, tmp); err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
// 通过仍打开的文件句柄设置 mtime,避免按 tmpName 重新解析路径产生 symlink TOCTOU。
|
||||
// 时间对齐延续既有 best-effort 语义,失败不影响已完整下载内容的发布。
|
||||
if rf.ModifiedTimeValid {
|
||||
_ = setPullFileTimes(tmp, time.UnixMilli(rf.ModifiedTime))
|
||||
}
|
||||
if err := pullSyncTemp(tmp); err != nil {
|
||||
return pullActionFailed, fmt.Errorf("同步临时文件失败: %w", err)
|
||||
}
|
||||
if err := pullCloseTemp(tmp); err != nil {
|
||||
return pullActionFailed, fmt.Errorf("关闭临时文件失败: %w", err)
|
||||
}
|
||||
currentTmp, err := pullRootLstat(target.parentRoot, tmpName)
|
||||
if err != nil || !os.SameFile(tmpInfo, currentTmp) {
|
||||
return pullActionFailed, fmt.Errorf("临时文件在下载期间被替换,已中止发布")
|
||||
}
|
||||
// 网络传输期间父目录仍可能被移走或替换。发布前再核对父目录身份和
|
||||
// 目标类型;不一致时只删除固定目录内的临时文件,绝不发布。
|
||||
if err := target.verifyParent(); err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
currentTarget, err := target.regularTargetInfo()
|
||||
if err != nil {
|
||||
return pullActionFailed, err
|
||||
}
|
||||
if ifExists != ifExistsOverwrite {
|
||||
if currentTarget != nil && (initialInfo == nil || !os.SameFile(initialInfo, currentTarget)) {
|
||||
return pullActionSkipped, nil
|
||||
}
|
||||
if ifExists == ifExistsSmart && currentTarget != nil && rf.ModifiedTimeValid && currentTarget.ModTime().UnixMilli() >= rf.ModifiedTime {
|
||||
return pullActionSkipped, nil
|
||||
}
|
||||
}
|
||||
if currentTarget == nil && ifExists != ifExistsOverwrite {
|
||||
if err := pullLink(target.parentRoot, tmpName, target.name); err != nil {
|
||||
if concurrent, statErr := target.regularTargetInfo(); statErr == nil && concurrent != nil {
|
||||
return pullActionSkipped, nil
|
||||
}
|
||||
return pullActionFailed, fmt.Errorf("发布目标文件失败: %w", err)
|
||||
}
|
||||
if err := pullRemove(target.parentRoot, tmpName); err != nil {
|
||||
return pullActionFailed, fmt.Errorf("清理临时文件失败: %w", err)
|
||||
}
|
||||
} else if err := pullRename(target.parentRoot, tmpName, target.name); err != nil {
|
||||
return pullActionFailed, fmt.Errorf("替换目标文件失败: %w", err)
|
||||
}
|
||||
finalInfo, err := pullRootLstat(target.parentRoot, target.name)
|
||||
if err != nil || !os.SameFile(tmpInfo, finalInfo) {
|
||||
// 身份不匹配意味着发布后 terminal entry 可能已被并发替换;保留未知对象并报错,
|
||||
// 不做 Lstat→Remove 的第二次 check/use,以免误删用户并发创建的文件。
|
||||
return pullActionFailed, fmt.Errorf("发布后的目标文件身份不一致")
|
||||
}
|
||||
if err := target.verifyParent(); err != nil {
|
||||
// 固定目录已从命令根路径移走时,保留句柄内已完成的结果并报失败;不能无条件
|
||||
// Remove,因为发布后同名 terminal entry 仍可能被并发替换。
|
||||
return pullActionFailed, err
|
||||
}
|
||||
committed = true
|
||||
return pullActionDownloaded, nil
|
||||
}
|
||||
|
||||
type pinnedPullRoot struct {
|
||||
absDir string
|
||||
root *os.Root
|
||||
rootInfo os.FileInfo
|
||||
pathInfo os.FileInfo
|
||||
}
|
||||
|
||||
// pinnedPullRootPlan 在远端清单读取前固定 pull 的本地根状态。existing 非空表示
|
||||
// absDir 当时已存在;否则 ancestor 固定最近既存祖先,missing 保存从祖先到 absDir
|
||||
// 的逐层目录名。missing 只有在完整远端清单和本地预检通过后才允许物化。
|
||||
type pinnedPullRootPlan struct {
|
||||
absDir string
|
||||
existing *pinnedPullRoot
|
||||
ancestor *pinnedPullRoot
|
||||
missing []string
|
||||
}
|
||||
|
||||
type pinnedPullTarget struct {
|
||||
base *pinnedPullRoot
|
||||
parentRoot *os.Root
|
||||
parentChain []pinnedPullDirIdentity
|
||||
name string
|
||||
ownsBase bool
|
||||
}
|
||||
|
||||
type pinnedPullDirIdentity struct {
|
||||
rel string
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
func planPinnedPullRoot(absDir string) (*pinnedPullRootPlan, error) {
|
||||
info, err := pullPathStat(absDir)
|
||||
if err == nil {
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("创建本地目录失败: %s 已存在且不是目录", absDir)
|
||||
}
|
||||
root, openErr := openExistingPinnedPullRoot(absDir)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
return &pinnedPullRootPlan{absDir: absDir, existing: root}, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("检查本地目录失败: %w", err)
|
||||
}
|
||||
|
||||
ancestorPath := absDir
|
||||
missing := make([]string, 0, 4)
|
||||
for {
|
||||
parent := filepath.Dir(ancestorPath)
|
||||
// absDir 已是验证过的绝对路径;文件系统根目录始终存在,
|
||||
// 因此循环必然在到达 parent == ancestorPath 之前以 Stat 成功结束。
|
||||
name := filepath.Base(ancestorPath)
|
||||
missing = append([]string{name}, missing...)
|
||||
ancestorPath = parent
|
||||
info, statErr := pullPathStat(ancestorPath)
|
||||
if statErr == nil {
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("创建本地目录失败: %s 已存在且不是目录", ancestorPath)
|
||||
}
|
||||
break
|
||||
}
|
||||
if !os.IsNotExist(statErr) {
|
||||
return nil, fmt.Errorf("检查本地目录失败: %w", statErr)
|
||||
}
|
||||
}
|
||||
ancestor, openErr := openExistingPinnedPullRoot(ancestorPath)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
plan := &pinnedPullRootPlan{absDir: absDir, ancestor: ancestor, missing: missing}
|
||||
if verifyErr := plan.verify(); verifyErr != nil {
|
||||
plan.close()
|
||||
return nil, verifyErr
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (plan *pinnedPullRootPlan) verify() error {
|
||||
if plan.existing != nil {
|
||||
return plan.existing.verify()
|
||||
}
|
||||
if plan.ancestor == nil {
|
||||
return fmt.Errorf("本地根目录计划无效")
|
||||
}
|
||||
if err := plan.ancestor.verify(); err != nil {
|
||||
return err
|
||||
}
|
||||
prefix := ""
|
||||
for _, segment := range plan.missing {
|
||||
prefix = filepath.Join(prefix, segment)
|
||||
_, err := pullRootLstat(plan.ancestor.root, prefix)
|
||||
if err == nil {
|
||||
return fmt.Errorf("本地根目录 %q 在远端读取期间被占用,已中止写入", plan.absDir)
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("检查本地根目录计划失败: %w", err)
|
||||
}
|
||||
// 第一层缺失后更深的路径不可能存在;物化时每层 Mkdir 仍以 no-clobber
|
||||
// 语义拒绝 verify 与创建之间的并发抢占。
|
||||
break
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (plan *pinnedPullRootPlan) materialize() (*pinnedPullRoot, error) {
|
||||
if err := plan.verify(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if plan.existing != nil {
|
||||
root := plan.existing
|
||||
plan.existing = nil
|
||||
return root, nil
|
||||
}
|
||||
|
||||
current, err := pullOpenParentRoot(plan.ancestor.root, ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("固定本地根祖先失败: %w", err)
|
||||
}
|
||||
for _, segment := range plan.missing {
|
||||
if err := pullRootMkdir(current, segment, 0o755); err != nil {
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("创建本地目录失败: %w", err)
|
||||
}
|
||||
createdInfo, err := pullRootLstat(current, segment)
|
||||
if err != nil || !createdInfo.IsDir() {
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("读取新建本地目录身份失败")
|
||||
}
|
||||
next, err := pullOpenParentRoot(current, segment)
|
||||
if err != nil {
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("固定新建本地目录失败: %w", err)
|
||||
}
|
||||
nextInfo, statErr := pullRootStat(next, ".")
|
||||
if statErr != nil || !os.SameFile(createdInfo, nextInfo) {
|
||||
_ = next.Close()
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("新建本地目录在固定期间被替换,已中止写入")
|
||||
}
|
||||
_ = current.Close()
|
||||
current = next
|
||||
}
|
||||
rootInfo, err := pullRootStat(current, ".")
|
||||
if err != nil {
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("读取本地根目录身份失败: %w", err)
|
||||
}
|
||||
pathInfo, err := pullPathLstat(plan.absDir)
|
||||
if err != nil {
|
||||
_ = current.Close()
|
||||
return nil, fmt.Errorf("读取本地根目录路径身份失败: %w", err)
|
||||
}
|
||||
root := &pinnedPullRoot{absDir: plan.absDir, root: current, rootInfo: rootInfo, pathInfo: pathInfo}
|
||||
if err := root.verify(); err != nil {
|
||||
root.close()
|
||||
return nil, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (plan *pinnedPullRootPlan) close() {
|
||||
if plan.existing != nil {
|
||||
plan.existing.close()
|
||||
plan.existing = nil
|
||||
}
|
||||
if plan.ancestor != nil {
|
||||
plan.ancestor.close()
|
||||
plan.ancestor = nil
|
||||
}
|
||||
}
|
||||
|
||||
func openPinnedPullRoot(absDir string) (*pinnedPullRoot, error) {
|
||||
if err := pullMkdirAll(absDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建本地目录失败: %w", err)
|
||||
}
|
||||
return openExistingPinnedPullRoot(absDir)
|
||||
}
|
||||
|
||||
// openExistingPinnedPullRoot 只固定已经存在的本地目录。push/sync 必须用这一入口:
|
||||
// 本地源根不存在时直接失败,不能为了扫描或上传隐式创建一个空源目录。
|
||||
func openExistingPinnedPullRoot(absDir string) (*pinnedPullRoot, error) {
|
||||
baseRoot, err := pullOpenRoot(absDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开本地根目录失败: %w", err)
|
||||
}
|
||||
rootInfo, err := pullRootStat(baseRoot, ".")
|
||||
if err != nil {
|
||||
_ = baseRoot.Close()
|
||||
return nil, fmt.Errorf("读取本地根目录身份失败: %w", err)
|
||||
}
|
||||
pathInfo, err := pullPathLstat(absDir)
|
||||
if err != nil {
|
||||
_ = baseRoot.Close()
|
||||
return nil, fmt.Errorf("读取本地根目录路径身份失败: %w", err)
|
||||
}
|
||||
root := &pinnedPullRoot{absDir: absDir, root: baseRoot, rootInfo: rootInfo, pathInfo: pathInfo}
|
||||
if err := root.verify(); err != nil {
|
||||
root.close()
|
||||
return nil, err
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func openPinnedPullTarget(absDir, rel string) (*pinnedPullTarget, error) {
|
||||
root, err := openPinnedPullRoot(absDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target, err := root.openTarget(rel)
|
||||
if err != nil {
|
||||
root.close()
|
||||
return nil, err
|
||||
}
|
||||
target.ownsBase = true
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (root *pinnedPullRoot) openTarget(rel string) (*pinnedPullTarget, error) {
|
||||
if err := root.verify(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localPath := filepath.Join(root.absDir, filepath.FromSlash(rel))
|
||||
localRel, err := pullRelPath(root.absDir, localPath)
|
||||
if err != nil || localRel == ".." || strings.HasPrefix(localRel, ".."+string(filepath.Separator)) {
|
||||
return nil, fmt.Errorf("远端路径 %q 逃逸出本地根目录", rel)
|
||||
}
|
||||
parentRel := filepath.Dir(localRel)
|
||||
parentRoot, parentChain, err := root.openTargetParent(parentRel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := &pinnedPullTarget{
|
||||
base: root, parentRoot: parentRoot, parentChain: parentChain,
|
||||
name: filepath.Base(localRel),
|
||||
}
|
||||
if err := target.verifyParent(); err != nil {
|
||||
target.close()
|
||||
return nil, err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// openTargetParent 从固定根开始逐层创建并打开目标父目录。os.Root 会安全地阻止
|
||||
// 软链接逃逸根外,但允许跟随仍指向根内的相对软链接;镜像语义不能接受这种重定向,
|
||||
// 否则 remote sub/a.txt 可能覆盖同一根下 victim/a.txt。因此每一层都必须:
|
||||
//
|
||||
// 1. 以 Lstat 拒绝软链接 / junction 及非目录;
|
||||
// 2. 通过上一层已固定的 Root 打开下一层;
|
||||
// 3. 再以 Lstat + SameFile 证明打开的就是目录项本身,而不是竞态中换入的链接目标。
|
||||
//
|
||||
// parentChain 保存每一层目录项身份,后续每次 verifyParent 都从命令根重新复核。
|
||||
func (root *pinnedPullRoot) openTargetParent(parentRel string) (*os.Root, []pinnedPullDirIdentity, error) {
|
||||
current, err := pullOpenParentRoot(root.root, ".")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("固定本地目标目录失败: %w", err)
|
||||
}
|
||||
chain := make([]pinnedPullDirIdentity, 0, 4)
|
||||
if parentRel == "." {
|
||||
info, err := pullRootLstat(root.root, ".")
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("本地目标目录不是可固定的真实目录")
|
||||
}
|
||||
openedInfo, statErr := pullRootStat(current, ".")
|
||||
if statErr != nil || !os.SameFile(info, openedInfo) {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("本地目标目录在固定期间被替换,已中止写入")
|
||||
}
|
||||
chain = append(chain, pinnedPullDirIdentity{rel: ".", info: info})
|
||||
return current, chain, nil
|
||||
}
|
||||
|
||||
prefix := ""
|
||||
for _, segment := range strings.Split(parentRel, string(filepath.Separator)) {
|
||||
info, lstatErr := pullRootLstat(current, segment)
|
||||
if os.IsNotExist(lstatErr) {
|
||||
if mkdirErr := pullRootMkdir(current, segment, 0o755); mkdirErr != nil {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("创建本地目录失败: %w", mkdirErr)
|
||||
}
|
||||
info, lstatErr = pullRootLstat(current, segment)
|
||||
}
|
||||
if lstatErr != nil {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("检查本地目标目录失败: %w", lstatErr)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("本地目标目录 %q 已存在且不是可固定的真实目录,已拒绝镜像", filepath.Join(prefix, segment))
|
||||
}
|
||||
|
||||
next, openErr := pullOpenParentRoot(current, segment)
|
||||
if openErr != nil {
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("固定本地目标目录失败: %w", openErr)
|
||||
}
|
||||
openedInfo, statErr := pullRootStat(next, ".")
|
||||
currentInfo, currentErr := pullRootLstat(current, segment)
|
||||
if statErr != nil || currentErr != nil || !currentInfo.IsDir() || currentInfo.Mode()&os.ModeSymlink != 0 ||
|
||||
!os.SameFile(info, currentInfo) || !os.SameFile(currentInfo, openedInfo) {
|
||||
_ = next.Close()
|
||||
_ = current.Close()
|
||||
return nil, nil, fmt.Errorf("本地目标目录在固定期间被替换,已中止写入")
|
||||
}
|
||||
prefix = filepath.Join(prefix, segment)
|
||||
chain = append(chain, pinnedPullDirIdentity{rel: prefix, info: currentInfo})
|
||||
_ = current.Close()
|
||||
current = next
|
||||
}
|
||||
return current, chain, nil
|
||||
}
|
||||
|
||||
func (root *pinnedPullRoot) verify() error {
|
||||
current, err := pullPathStat(root.absDir)
|
||||
currentPath, pathErr := pullPathLstat(root.absDir)
|
||||
if err != nil || pathErr != nil || !os.SameFile(root.rootInfo, current) || !os.SameFile(root.pathInfo, currentPath) {
|
||||
return fmt.Errorf("本地根目录在同步期间被替换,已中止写入")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (root *pinnedPullRoot) close() { _ = root.root.Close() }
|
||||
|
||||
func (target *pinnedPullTarget) close() {
|
||||
_ = target.parentRoot.Close()
|
||||
if target.ownsBase {
|
||||
target.base.close()
|
||||
}
|
||||
}
|
||||
|
||||
func (target *pinnedPullTarget) verifyParent() error {
|
||||
if err := target.base.verify(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, expected := range target.parentChain {
|
||||
current, err := pullRootLstat(target.base.root, expected.rel)
|
||||
if err != nil || !current.IsDir() || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(expected.info, current) {
|
||||
return fmt.Errorf("本地目标目录在下载期间被替换,已中止写入")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *pinnedPullTarget) regularTargetInfo() (os.FileInfo, error) {
|
||||
info, err := pullRootLstat(target.parentRoot, target.name)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("检查本地目标失败: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", target.name)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func createPinnedPullTemp(root *os.Root) (*os.File, string, error) {
|
||||
for i := 0; i < 100; i++ {
|
||||
name := ".dws-pull-" + pullTempName()
|
||||
file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err == nil {
|
||||
return file, name, nil
|
||||
}
|
||||
if !os.IsExist(err) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("创建不重名临时文件失败")
|
||||
}
|
||||
|
||||
func defaultPullDownloadFile(ctx context.Context, url string, headers map[string]string, destination *os.File) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := pullHTTPDo(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
_, err = io.Copy(destination, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// rejectNonRegularLocalTarget 在任何下载工具调用或临时文件创建之前,拒绝已存在但
|
||||
// 不是常规文件的镜像目标。不存在的目标可由后续流程安全创建。
|
||||
func rejectNonRegularLocalTarget(localPath string) error {
|
||||
info, err := pullTargetLstat(localPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("检查本地目标失败: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", localPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// runDrivePull — 端到端:远端树 → 本地落盘
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// pullListingCaller 返回固定的单层远端清单,download_file 给出预签名链接。
|
||||
func pullListingCaller(items string) *driveScriptCaller {
|
||||
return &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
|
||||
switch tool {
|
||||
case "list_files":
|
||||
if nth > 0 {
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[` + items + `],"nextToken":""}}`, nil
|
||||
case "download_file":
|
||||
return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil
|
||||
}
|
||||
return `{"result":{},"success":true}`, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// 端到端 pull:远端两个文件都下载到本地,并自动创建缺失的本地根目录。
|
||||
func TestCrossPlatformCoverageDrivePull_endToEndDownloadsAll(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "created-by-pull")
|
||||
caller := pullListingCaller(
|
||||
`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1000},` +
|
||||
`{"name":"b.txt","type":"file","fileId":"B","modifyTime":2000}`)
|
||||
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("remote"), 0o644)
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, name := range []string{"a.txt", "b.txt"} {
|
||||
if b, err := os.ReadFile(filepath.Join(root, name)); err != nil || string(b) != "remote" {
|
||||
t.Errorf("%s not written: %v / %q", name, err, string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --if-exists skip:本地已存在则不下载。
|
||||
func TestCrossPlatformCoverageDrivePull_ifExistsSkipKeepsLocal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "local-original")
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9000}`)
|
||||
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
t.Error("skip must not download")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "skip"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(root, "a.txt")); string(b) != "local-original" {
|
||||
t.Errorf("local file changed: %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// --if-exists smart:本地 mtime 已 ≥ 远端 → 跳过;远端更新 → 下载。
|
||||
func TestCrossPlatformCoverageDrivePull_ifExistsSmart(t *testing.T) {
|
||||
t.Run("local newer skips", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "a.txt")
|
||||
mustWrite(t, p, "local")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":` + strconv.FormatInt(local-5000, 10) + `}`)
|
||||
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
t.Error("smart must not download when local is newer")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "local" {
|
||||
t.Errorf("local file changed: %q", string(b))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote newer downloads", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "a.txt")
|
||||
mustWrite(t, p, "local")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":` + strconv.FormatInt(local+60000, 10) + `}`)
|
||||
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("fresh"), 0o644)
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "fresh" {
|
||||
t.Errorf("expected download, got %q", string(b))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --if-exists 非法值直接拒绝。
|
||||
func TestCrossPlatformCoverageDrivePull_invalidIfExistsRejected(t *testing.T) {
|
||||
caller := pullListingCaller("")
|
||||
err := runDriveCmd(t, caller, "pull", "--local-folder", t.TempDir(), "--remote-folder", "ROOT", "--if-exists", "bogus")
|
||||
if err == nil || !strings.Contains(err.Error(), "--if-exists") {
|
||||
t.Fatalf("expected --if-exists rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// space-id 透传到 download_file。
|
||||
func TestCrossPlatformCoverageDrivePull_passesSpaceIDToDownload(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`)
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("x"), 0o644)
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--space-id", "SP9"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := caller.callsFor("download_file")[0].args["spaceId"]; got != "SP9" {
|
||||
t.Errorf("download_file spaceId = %v, want SP9", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 下载失败 → 该项记 failed,命令以 partial_failure 非零退出。
|
||||
func TestCrossPlatformCoverageDrivePull_downloadFailureIsPartialFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`)
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { return errTestDownload })
|
||||
|
||||
var out strings.Builder
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"})
|
||||
|
||||
cmd := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
err := runDrivePull(cmd, nil)
|
||||
var pf *drivePartialFailure
|
||||
if !errors.As(err, &pf) {
|
||||
t.Fatalf("expected drivePartialFailure, got %T %v", err, err)
|
||||
}
|
||||
if pf.failed != 1 || pf.RawStderr() != "drive pull: 1 file(s) failed" {
|
||||
t.Errorf("partial failure = %#v, stderr = %q", pf, pf.RawStderr())
|
||||
}
|
||||
stdout := out.String()
|
||||
if !strings.Contains(stdout, `"failed": 1`) || !strings.Contains(stdout, `"rel_path": "a.txt"`) {
|
||||
t.Errorf("stdout must retain the structured partial result: %s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// download_file 未返回下载链接 → failed(不是 panic,也不落盘)。
|
||||
func TestCrossPlatformCoverageDrivePull_missingDownloadURLFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
|
||||
if tool == "list_files" {
|
||||
if nth > 0 {
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"fileId":"A"},"success":true}`, nil // 无 downloadUrl
|
||||
}}
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected failure when download_file returns no URL")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "a.txt")); !os.IsNotExist(err) {
|
||||
t.Error("nothing must be written when the download URL is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// download_file 的 MCP 调用本身失败 → failed。
|
||||
func TestCrossPlatformCoverageDrivePull_downloadToolErrorFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
|
||||
if tool == "list_files" {
|
||||
if nth > 0 {
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":""}}`, nil
|
||||
}
|
||||
return "", errTestDownload
|
||||
}}
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected failure when download_file errors")
|
||||
}
|
||||
}
|
||||
|
||||
// 远端名称逃逸出本地根 → 记 failed,不落盘(resolveLocalTarget 二次确认)。
|
||||
func TestCrossPlatformCoverageDrivePull_escapingRelPathIsFailed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// 远端目录名合法,但目录内的软链把落盘点指到根目录外。
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
caller := &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
return `{"result":{"downloadUrl":"https://oss.example.com/get"}}`, nil
|
||||
}
|
||||
switch args["parentId"] {
|
||||
case "ROOT":
|
||||
return `{"result":{"items":[{"name":"sub","type":"folder","fileId":"SUB"}],"nextToken":""}}`, nil
|
||||
case "SUB":
|
||||
return `{"result":{"items":[{"name":"leaked.txt","type":"file","fileId":"L"}],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}}
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("leak"), 0o644)
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected symlink escape to be reported as failure")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "leaked.txt")); !os.IsNotExist(err) {
|
||||
t.Error("file escaped the local root")
|
||||
}
|
||||
}
|
||||
|
||||
// 目标父目录不可创建(同名常规文件占位)→ failed,不 panic。
|
||||
func TestCrossPlatformCoveragePullOneFile_mkdirFailureIsFailed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// "sub" 是普通文件,MkdirAll("sub") 必然失败。
|
||||
mustWrite(t, filepath.Join(root, "sub"), "occupied")
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
rf := &remoteFile{RelPath: "sub/x.txt", FileID: "X"}
|
||||
action, err := pullOneFile(context.Background(), "", rf, filepath.Join(root, "sub", "x.txt"), ifExistsOverwrite)
|
||||
if action != pullActionFailed || err == nil {
|
||||
t.Fatalf("action=%q err=%v, want failed + error", action, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 探测目录不存在时 isCaseInsensitiveFS 回退平台默认,不 panic。
|
||||
func TestCrossPlatformCoverageIsCaseInsensitiveFS_missingDirFallsBackToPlatformDefault(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "absent")
|
||||
// 只断言不 panic 且返回布尔(具体值取决于平台默认)。
|
||||
_ = isCaseInsensitiveFS(missing)
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func newPinnedPullRootForCoverage(t *testing.T, path string) *pinnedPullRoot {
|
||||
t.Helper()
|
||||
root, err := openExistingPinnedPullRoot(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(root.close)
|
||||
return root
|
||||
}
|
||||
|
||||
func newMissingPinnedPullPlanForCoverage(t *testing.T) *pinnedPullRootPlan {
|
||||
t.Helper()
|
||||
root := filepath.Join(t.TempDir(), "missing", "mirror")
|
||||
plan, err := planPinnedPullRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(plan.close)
|
||||
return plan
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullRootPlanFailures(t *testing.T) {
|
||||
t.Run("existing root open", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullOpenRoot, func(string) (*os.Root, error) { return nil, errTestDownload })
|
||||
if _, err := planPinnedPullRoot(t.TempDir()); err == nil {
|
||||
t.Fatal("expected existing-root open failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ancestor is a file", func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
file := filepath.Join(parent, "file")
|
||||
mustWrite(t, file, "x")
|
||||
if _, err := planPinnedPullRoot(filepath.Join(file, "child")); err == nil ||
|
||||
(!strings.Contains(err.Error(), "不是目录") && !strings.Contains(err.Error(), "not a directory")) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ancestor discovered as file", func(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "file")
|
||||
mustWrite(t, file, "x")
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullPathStat, func(string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return os.Stat(file)
|
||||
})
|
||||
if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil || !strings.Contains(err.Error(), "不是目录") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ancestor open", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullOpenRoot, func(string) (*os.Root, error) { return nil, errTestDownload })
|
||||
if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected ancestor open failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path stat errors", func(t *testing.T) {
|
||||
for _, initial := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "initial", false: "ancestor"}[initial], func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if initial || calls > 1 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected path stat failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("initial verification", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
if _, err := planPinnedPullRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected initial plan verification failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid and changed plan", func(t *testing.T) {
|
||||
if err := (&pinnedPullRootPlan{}).verify(); err == nil {
|
||||
t.Fatal("expected invalid plan failure")
|
||||
}
|
||||
root := newPinnedPullRootForCoverage(t, t.TempDir())
|
||||
bad := *root
|
||||
bad.absDir = filepath.Join(t.TempDir(), "gone")
|
||||
if err := (&pinnedPullRootPlan{ancestor: &bad}).verify(); err == nil {
|
||||
t.Fatal("expected changed ancestor failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unexpected missing-path lstat", func(t *testing.T) {
|
||||
plan := newMissingPinnedPullPlanForCoverage(t)
|
||||
testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
if err := plan.verify(); err == nil {
|
||||
t.Fatal("expected missing-path lstat failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullRootMaterializeFailures(t *testing.T) {
|
||||
t.Run("verification", func(t *testing.T) {
|
||||
if _, err := (&pinnedPullRootPlan{}).materialize(); err == nil {
|
||||
t.Fatal("expected materialize verification failure")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stub func(*testing.T)
|
||||
}{
|
||||
{"open ancestor", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullOpenParentRoot, func(*os.Root, string) (*os.Root, error) { return nil, errTestDownload })
|
||||
}},
|
||||
{"mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullRootMkdir, func(*os.Root, string, os.FileMode) error { return errTestDownload })
|
||||
}},
|
||||
{"created lstat", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
}},
|
||||
{"created not directory", func(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "file")
|
||||
mustWrite(t, file, "x")
|
||||
info, err := os.Lstat(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return info, nil })
|
||||
}},
|
||||
{"created changes before root identity", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
}},
|
||||
{"open created", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullOpenParentRoot, func(root *os.Root, name string) (*os.Root, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.OpenRoot(name)
|
||||
})
|
||||
}},
|
||||
{"created identity", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.Stat(name)
|
||||
})
|
||||
}},
|
||||
{"final root stat", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 3 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.Stat(name)
|
||||
})
|
||||
}},
|
||||
{"final verification", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullPathLstat, func(path string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls >= 2 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return os.Lstat(path)
|
||||
})
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plan := newMissingPinnedPullPlanForCoverage(t)
|
||||
tc.stub(t)
|
||||
if root, err := plan.materialize(); err == nil {
|
||||
root.close()
|
||||
t.Fatal("expected materialize failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("final lstat and verify", func(t *testing.T) {
|
||||
for _, failAt := range []int{1, 2} {
|
||||
t.Run(string(rune('0'+failAt)), func(t *testing.T) {
|
||||
plan := newMissingPinnedPullPlanForCoverage(t)
|
||||
calls := 0
|
||||
if failAt == 1 {
|
||||
testseam.Swap(t, &pullPathLstat, func(string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
} else {
|
||||
testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls >= 2 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
}
|
||||
if root, err := plan.materialize(); err == nil {
|
||||
root.close()
|
||||
t.Fatal("expected final materialize failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("open existing path identity and verification", func(t *testing.T) {
|
||||
for _, fail := range []string{"lstat", "verify"} {
|
||||
t.Run(fail, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if fail == "lstat" {
|
||||
testseam.Swap(t, &pullPathLstat, func(string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
} else {
|
||||
pathCalls := 0
|
||||
testseam.Swap(t, &pullPathLstat, func(path string) (os.FileInfo, error) {
|
||||
pathCalls++
|
||||
if pathCalls >= 2 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return os.Lstat(path)
|
||||
})
|
||||
}
|
||||
if _, err := openExistingPinnedPullRoot(dir); err == nil {
|
||||
t.Fatal("expected existing-root identity failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullPublishRaces(t *testing.T) {
|
||||
t.Run("smart concurrent target", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "older")
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(filepath.Join(root, "a.txt"), time.Now(), time.Now().Add(time.Hour))
|
||||
})
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F", ModifiedTimeValid: true}, root, "a.txt", ifExistsSmart)
|
||||
if err != nil || action != pullActionSkipped {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("link loses to regular target", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullLink, func(parent *os.Root, _, target string) error {
|
||||
file, err := parent.OpenFile(target, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
return os.ErrExist
|
||||
})
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsSkip)
|
||||
if err != nil || action != pullActionSkipped {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("link cleanup", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullRemove, func(*os.Root, string) error { return errTestDownload })
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsSkip)
|
||||
if err == nil || action != pullActionFailed || !strings.Contains(err.Error(), "清理临时文件") {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post publish parent", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
statCalls := 0
|
||||
testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) {
|
||||
statCalls++
|
||||
if statCalls >= 5 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite)
|
||||
if err == nil || action != pullActionFailed {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("overwrite rename", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullRename, func(*os.Root, string, string) error { return errTestDownload })
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite)
|
||||
if err == nil || action != pullActionFailed {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullPlanCallers(t *testing.T) {
|
||||
t.Run("second preflight verification", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
|
||||
root := newPinnedPullRootForCoverage(t, dir)
|
||||
plan := &pinnedPullRootPlan{absDir: root.absDir, existing: root}
|
||||
moved := false
|
||||
testseam.Swap(t, &pullRootLstat, func(parent *os.Root, name string) (os.FileInfo, error) {
|
||||
info, err := parent.Lstat(name)
|
||||
if name == "a.txt" && !moved {
|
||||
moved = true
|
||||
if renameErr := os.Rename(dir, dir+"-pinned"); renameErr != nil {
|
||||
// Windows 在固定目录上持有句柄时会锁住它,移走于该平台物理
|
||||
// 不可达;注入等价的根身份变化,命中同一条二次校验分支。
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Fatal(renameErr)
|
||||
}
|
||||
swapPinnedRootIdentity(t, dir)
|
||||
} else if mkdirErr := os.Mkdir(dir, 0o755); mkdirErr != nil {
|
||||
t.Fatal(mkdirErr)
|
||||
}
|
||||
}
|
||||
return info, err
|
||||
})
|
||||
if _, _, err := buildDrivePullPreflightFromPlan(plan, map[string]*remoteFile{"a.txt": {RelPath: "a.txt"}}); err == nil {
|
||||
t.Fatal("expected second verification failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("command materialize", func(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "missing")
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A"}`)
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
testseam.Swap(t, &pullOpenParentRoot, func(*os.Root, string) (*os.Root, error) { return nil, errTestDownload })
|
||||
cmd := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDrivePull(cmd, nil); err == nil || !errors.Is(err, errTestDownload) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preflight output", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
caller := pullListingCaller(`{"name":"A.txt","type":"file","fileId":"A"},{"name":"a.txt","type":"file","fileId":"B"}`)
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
cmd := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDrivePull(cmd, nil); err == nil {
|
||||
t.Fatal("expected preflight output failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote helper", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
action, err := pullRemoteFile(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite)
|
||||
if err != nil || action != pullActionDownloaded {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("target construction final verification", func(t *testing.T) {
|
||||
root := newPinnedPullRootForCoverage(t, t.TempDir())
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootLstat, func(parent *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls >= 2 && name == "." {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return parent.Lstat(name)
|
||||
})
|
||||
if _, err := root.openTarget("a.txt"); err == nil {
|
||||
t.Fatal("expected target verification failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullDefaultTransportWithoutListener(t *testing.T) {
|
||||
destination, err := os.Create(filepath.Join(t.TempDir(), "payload"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer destination.Close()
|
||||
|
||||
if err := defaultPullDownloadFile(context.Background(), ":", nil, destination); err == nil {
|
||||
t.Fatal("expected invalid request URL")
|
||||
}
|
||||
|
||||
t.Run("transport error", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullHTTPDo, func(*http.Request) (*http.Response, error) { return nil, errTestDownload })
|
||||
if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", nil, destination); !errors.Is(err, errTestDownload) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("status", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullHTTPDo, func(request *http.Request) (*http.Response, error) {
|
||||
if request.Header.Get("X-Test") != "yes" {
|
||||
t.Fatalf("header = %q", request.Header.Get("X-Test"))
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusTeapot, Body: io.NopCloser(strings.NewReader("denied"))}, nil
|
||||
})
|
||||
if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", map[string]string{"X-Test": "yes"}, destination); err == nil {
|
||||
t.Fatal("expected status failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success and copy", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullHTTPDo, func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload"))}, nil
|
||||
})
|
||||
if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", nil, destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closed, createErr := os.Create(filepath.Join(t.TempDir(), "closed"))
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
_ = closed.Close()
|
||||
if err := defaultPullDownloadFile(context.Background(), "https://example.invalid", nil, closed); err == nil {
|
||||
t.Fatal("expected copy failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullMTimeHandle(t *testing.T) {
|
||||
file, err := os.Create(filepath.Join(t.TempDir(), "mtime"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
if err := setPullFileTimes(file, time.Unix(123, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullDryRunLocalPolicies(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "a.txt")
|
||||
mustWrite(t, path, "local")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: io.Discard}})
|
||||
remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", ModifiedTimeValid: true, ModifiedTime: info.ModTime().UnixMilli()}}
|
||||
for _, policy := range []string{ifExistsSkip, ifExistsSmart, ifExistsOverwrite} {
|
||||
if err := printDrivePullDryRunWithLocalInfo(policy, remote, []string{"a.txt"}, nil, map[string]os.FileInfo{"a.txt": info}); err != nil {
|
||||
t.Fatalf("policy %s: %v", policy, err)
|
||||
}
|
||||
}
|
||||
if err := printDrivePullDryRunWithLocalInfo(ifExistsSkip, remote, []string{"missing.txt"}, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullRejectNonRegular(t *testing.T) {
|
||||
if err := rejectNonRegularLocalTarget(filepath.Join(t.TempDir(), "missing")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := rejectNonRegularLocalTarget(dir); err == nil {
|
||||
t.Fatal("expected directory rejection")
|
||||
}
|
||||
testseam.Swap(t, &pullTargetLstat, func(string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
if err := rejectNonRegularLocalTarget("target"); err == nil {
|
||||
t.Fatal("expected lstat failure")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// smart 策略在入口按固定的 initialInfo 判定「本地落后、需要下载」,但远端读与落盘
|
||||
// 期间同一 inode 的 mtime 仍可能被并发写者推到 ≥ 远端时间。此时发布会用旧的远端版本
|
||||
// 覆盖更新的本地内容,因此必须在发布前按当前状态二次判定并跳过。
|
||||
func TestCrossPlatformCoverageDrivePullSmartSkipsWhenTargetCatchesUpDuringDownload(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "a.txt")
|
||||
const remoteMillis int64 = 2_000_000
|
||||
if err := os.WriteFile(target, []byte("local"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 入口处的 mtime 必须严格早于远端,否则在发出远端读之前就已跳过。
|
||||
stale := time.UnixMilli(remoteMillis - 60_000)
|
||||
if err := os.Chtimes(target, stale, stale); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
// 就地改时间:inode 不变,所以身份比较仍然成立,只有时间判定能拦下发布。
|
||||
fresh := time.UnixMilli(remoteMillis + 60_000)
|
||||
return os.Chtimes(target, fresh, fresh)
|
||||
})
|
||||
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{
|
||||
FileID: "F",
|
||||
ModifiedTime: remoteMillis,
|
||||
ModifiedTimeValid: true,
|
||||
}, root, "a.txt", ifExistsSmart)
|
||||
if err != nil {
|
||||
t.Fatalf("pull = %v", err)
|
||||
}
|
||||
if action != pullActionSkipped {
|
||||
t.Fatalf("action = %q, want %q", action, pullActionSkipped)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "local" {
|
||||
t.Fatalf("target = %q, err=%v; 追平后的本地内容必须保留", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 发布成功后固定目录仍可能被移走。此时结果已经落盘,门禁只能报失败而不能回删——
|
||||
// 同名 terminal entry 可能已被并发替换,二次 Lstat→Remove 会误删他人的文件。
|
||||
func TestCrossPlatformCoverageDrivePullFailsWhenPinnedRootMovesAfterPublish(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
decoy := t.TempDir()
|
||||
decoyInfo, err := os.Stat(decoy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
published := false
|
||||
testseam.Swap(t, &pullRename, func(parent *os.Root, oldName, newName string) error {
|
||||
if err := parent.Rename(oldName, newName); err != nil {
|
||||
return err
|
||||
}
|
||||
published = true
|
||||
return nil
|
||||
})
|
||||
// 只在发布之后让根目录身份不匹配:下载前与发布前的两次核对都必须先通过。
|
||||
testseam.Swap(t, &pullPathStat, func(path string) (os.FileInfo, error) {
|
||||
if published {
|
||||
return decoyInfo, nil
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite)
|
||||
if err == nil || !strings.Contains(err.Error(), "本地根目录在同步期间被替换") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if action != pullActionFailed {
|
||||
t.Fatalf("action = %q, want %q", action, pullActionFailed)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(got) != "remote" {
|
||||
t.Fatalf("published target = %q, err=%v; 已发布的结果不得被回删", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// drivePullMockCaller 按工具名分发:list_files 返回可配置的树,download_file 返回预签名 URL。
|
||||
type drivePullMockCaller struct{ listJSON string }
|
||||
|
||||
func (m *drivePullMockCaller) CallTool(_ context.Context, _ string, tool string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
text := `{"result":{"downloadUrl":"https://oss.example.com/dl"}}`
|
||||
if tool == "list_files" {
|
||||
text = m.listJSON
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
func (m *drivePullMockCaller) Format() string { return "raw" }
|
||||
func (m *drivePullMockCaller) DryRun() bool { return false }
|
||||
func (m *drivePullMockCaller) Fields() string { return "" }
|
||||
func (m *drivePullMockCaller) JQ() string { return "" }
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// isSafeRemoteSegment — 远端名称逐段安全校验(拒绝逃逸成分)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageIsSafeRemoteSegment(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"report.pdf", true},
|
||||
{"报告 2024.xlsx", true},
|
||||
{"a.b.c", true},
|
||||
{"", false},
|
||||
{".", false},
|
||||
{"..", false},
|
||||
{"a/b", false}, // 正斜杠分隔符
|
||||
{`a\b`, false}, // 反斜杠分隔符(Windows)
|
||||
{"../etc", false}, // 相对上跳
|
||||
{"foo/../bar", false},
|
||||
{"/abs", false}, // 绝对路径成分
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isSafeRemoteSegment(c.name); got != c.ok {
|
||||
t.Errorf("isSafeRemoteSegment(%q) = %v, want %v", c.name, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// resolveLocalTarget — 拼接后必须仍位于本地根目录内
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageResolveLocalTarget_withinRoot(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
got, err := resolveLocalTarget(root, "sub/a.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := filepath.Join(root, "sub", "a.txt")
|
||||
if got != want {
|
||||
t.Errorf("target = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 即便 isSafeRemoteSegment 被绕过,逐段拼出的逃逸路径也必须被 resolveLocalTarget 兜住。
|
||||
func TestCrossPlatformCoverageResolveLocalTarget_escapeRejected(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
for _, rel := range []string{"../evil.txt", "../../etc/passwd", "sub/../../out.txt"} {
|
||||
if _, err := resolveLocalTarget(root, rel); err == nil {
|
||||
t.Errorf("resolveLocalTarget(%q) should reject escaping path", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根目录内的目录符号链接指向外部时,词法检查会放过(root/sub 仍在 root 下),
|
||||
// 必须靠符号链接解析挡住:sub -> /outside 时 sub/a.txt 应被拒绝。
|
||||
func TestCrossPlatformCoverageResolveLocalTarget_dirSymlinkEscapeRejected(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
outside := filepath.Clean(t.TempDir())
|
||||
|
||||
link := filepath.Join(root, "sub")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("平台不支持创建符号链接: %v", err)
|
||||
}
|
||||
|
||||
if _, err := resolveLocalTarget(root, "sub/a.txt"); err == nil {
|
||||
t.Fatal("root/sub -> 外部目录时,sub/a.txt 应被拒绝(防符号链接逃逸)")
|
||||
}
|
||||
|
||||
// 对照:根目录内的真实子目录不应被误伤。
|
||||
if err := os.MkdirAll(filepath.Join(root, "real"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := resolveLocalTarget(root, "real/a.txt"); err != nil {
|
||||
t.Errorf("真实子目录不应被拒绝: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 不存在的本地根目录必须放行(不误判逃逸),由后续 MkdirAll 创建。
|
||||
func TestCrossPlatformCoverageResolveLocalTarget_nonexistentRootAllowed(t *testing.T) {
|
||||
parent := filepath.Clean(t.TempDir())
|
||||
absDir := filepath.Join(parent, "not", "created", "yet") // 尚不存在
|
||||
got, err := resolveLocalTarget(absDir, "sub/a.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("不存在的根目录不应被误判为逃逸: %v", err)
|
||||
}
|
||||
if want := filepath.Join(absDir, "sub", "a.txt"); got != want {
|
||||
t.Errorf("target = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:--local-folder 指向尚不存在的绝对路径时,pull 应自动创建目录并下载,
|
||||
// 而不是把每个远端文件都误判为符号链接逃逸而 failed。
|
||||
func TestCrossPlatformCoverageDrivePull_nonexistentLocalRoot(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
absDir := filepath.Join(parent, "new", "repo") // 尚不存在
|
||||
|
||||
caller := &drivePullMockCaller{
|
||||
listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"F1","modifyTime":1000}],"nextToken":""}}`,
|
||||
}
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("REMOTE"), 0o644)
|
||||
})
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.AddCommand(newDriveCommand())
|
||||
|
||||
// pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。
|
||||
full := []string{"pull", "--local-folder", absDir, "--remote-folder", "ROOT", "--yes"}
|
||||
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...))
|
||||
root.SetArgs(append([]string{"drive"}, full...))
|
||||
|
||||
// 全部下载成功时 runDrivePull 返回 nil;若误判逃逸则会有 failed 并返回 partial_failure。
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("pull 到不存在的本地根目录不应失败: %v", err)
|
||||
}
|
||||
got, rerr := os.ReadFile(filepath.Join(absDir, "a.txt"))
|
||||
if rerr != nil {
|
||||
t.Fatalf("目标文件应被创建: %v", rerr)
|
||||
}
|
||||
if string(got) != "REMOTE" {
|
||||
t.Errorf("目标内容 = %q, want REMOTE", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// detectTargetCollisions — 多个远端条目映射到同一本地目标
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 大小写不敏感 FS:A.txt 与 a.txt 落到同一目标 → 两者都判冲突;无关文件不受影响。
|
||||
func TestCrossPlatformCoverageDetectTargetCollisions_caseInsensitive(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
rels := []string{"A.txt", "a.txt", "b.txt", "sub/C.md", "sub/c.md"}
|
||||
collided := detectTargetCollisions(root, rels, true)
|
||||
|
||||
for _, r := range []string{"A.txt", "a.txt", "sub/C.md", "sub/c.md"} {
|
||||
if !collided[r] {
|
||||
t.Errorf("%q 应被判为大小写冲突", r)
|
||||
}
|
||||
}
|
||||
if collided["b.txt"] {
|
||||
t.Error("b.txt 无冲突,不应被标记")
|
||||
}
|
||||
}
|
||||
|
||||
// 大小写敏感 FS:A.txt 与 a.txt 是两个合法文件 → 不应误判冲突。
|
||||
func TestCrossPlatformCoverageDetectTargetCollisions_caseSensitive(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
rels := []string{"A.txt", "a.txt", "b.txt"}
|
||||
collided := detectTargetCollisions(root, rels, false)
|
||||
if len(collided) != 0 {
|
||||
t.Errorf("大小写敏感 FS 不应有冲突, got %v", collided)
|
||||
}
|
||||
}
|
||||
|
||||
// 平台名称规范化冲突:同名的 NFC 与 NFD 记法在不敏感 FS 上落到同一目标 → 冲突。
|
||||
func TestCrossPlatformCoverageDetectTargetCollisions_unicodeNormalization(t *testing.T) {
|
||||
root := filepath.Clean(t.TempDir())
|
||||
nfc := "café.txt" // café(预组合 é)
|
||||
nfd := "café.txt" // café(e + 组合尖音符)
|
||||
if nfc == nfd {
|
||||
t.Fatal("测试数据应为不同字节序列")
|
||||
}
|
||||
|
||||
ci := detectTargetCollisions(root, []string{nfc, nfd}, true)
|
||||
if !ci[nfc] || !ci[nfd] {
|
||||
t.Errorf("NFC/NFD 异写在大小写不敏感 FS 上应判冲突, got %v", ci)
|
||||
}
|
||||
// 大小写/规范化敏感 FS 上视为两个不同文件,不冲突。
|
||||
cs := detectTargetCollisions(root, []string{nfc, nfd}, false)
|
||||
if len(cs) != 0 {
|
||||
t.Errorf("敏感 FS 上 NFC/NFD 不应冲突, got %v", cs)
|
||||
}
|
||||
}
|
||||
|
||||
// isCaseInsensitiveFS 应能对临时目录给出与实际探针一致的判定(本机自洽)。
|
||||
func TestCrossPlatformCoverageIsCaseInsensitiveFS_selfConsistent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
got := isCaseInsensitiveFS(dir)
|
||||
// 实测:写一个小写名,再用大写名 stat。
|
||||
lower := filepath.Join(dir, "probe_case_xyz.txt")
|
||||
if err := os.WriteFile(lower, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := os.Stat(filepath.Join(dir, "PROBE_CASE_XYZ.TXT"))
|
||||
actualInsensitive := err == nil
|
||||
if got != actualInsensitive {
|
||||
t.Errorf("isCaseInsensitiveFS=%v,但实测大写名可 stat=%v", got, actualInsensitive)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// pullOneFile — 覆盖下载中断不得破坏原文件(原子替换)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 下载中途失败时:命令报告 failed,原有本地文件内容必须原封不动,且不留临时残file。
|
||||
func TestCrossPlatformCoveragePullOneFile_downloadFailureKeepsOriginal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "a.txt")
|
||||
const original = "ORIGINAL-CONTENT-DO-NOT-LOSE"
|
||||
mustWrite(t, target, original)
|
||||
|
||||
// download_file 的 MCP 调用走 mock;httpGetFile 注入为“写入部分内容后报错”。
|
||||
testseam.Swap(t, &deps, &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
// 模拟传输中断:先截断/写半截,再返回错误。
|
||||
_ = os.WriteFile(dest, []byte("HALF"), 0o644)
|
||||
return errors.New("connection reset")
|
||||
})
|
||||
|
||||
rf := &remoteFile{RelPath: "a.txt", FileID: "F1"}
|
||||
action, err := pullOneFile(context.Background(), "", rf, target, ifExistsOverwrite)
|
||||
if action != pullActionFailed || err == nil {
|
||||
t.Fatalf("expected failed action with error, got action=%q err=%v", action, err)
|
||||
}
|
||||
|
||||
// 原文件必须保持原内容。
|
||||
got, rerr := os.ReadFile(target)
|
||||
if rerr != nil {
|
||||
t.Fatalf("原文件应仍存在: %v", rerr)
|
||||
}
|
||||
if string(got) != original {
|
||||
t.Errorf("原文件被破坏: got %q, want %q", got, original)
|
||||
}
|
||||
|
||||
// 不应残留临时文件。
|
||||
entries, _ := os.ReadDir(root)
|
||||
for _, e := range entries {
|
||||
if e.Name() != "a.txt" {
|
||||
t.Errorf("下载失败后残留了临时文件: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下载成功时:临时文件被原子重命名为目标,内容与远端一致。
|
||||
func TestCrossPlatformCoveragePullOneFile_downloadSuccessReplacesTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "a.txt")
|
||||
mustWrite(t, target, "OLD")
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: &drivePullMockCaller{}, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("NEW-CONTENT"), 0o644)
|
||||
})
|
||||
|
||||
rf := &remoteFile{RelPath: "a.txt", FileID: "F1"}
|
||||
action, err := pullOneFile(context.Background(), "", rf, target, ifExistsOverwrite)
|
||||
if err != nil || action != pullActionDownloaded {
|
||||
t.Fatalf("expected downloaded, got action=%q err=%v", action, err)
|
||||
}
|
||||
got, _ := os.ReadFile(target)
|
||||
if string(got) != "NEW-CONTENT" {
|
||||
t.Errorf("目标未被替换为新内容: got %q", got)
|
||||
}
|
||||
entries, _ := os.ReadDir(root)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("成功后目录应只有目标文件,got %d 项", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// parseDriveDownloadInfo — drive download_file 返回解析
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// drive 的正常返回:result.downloadUrl 是预签名 URL,无需额外 header。
|
||||
func TestCrossPlatformCoverageParseDriveDownloadInfo_downloadUrl(t *testing.T) {
|
||||
text := `{"result":{"downloadType":"urlPreSignature","downloadUrl":"https://oss.example.com/f.file?Expires=1&Signature=xyz","fileName":"a.txt"},"success":true}`
|
||||
url, headers, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if url != "https://oss.example.com/f.file?Expires=1&Signature=xyz" {
|
||||
t.Errorf("url = %q", url)
|
||||
}
|
||||
if len(headers) != 0 {
|
||||
t.Errorf("预签名 URL 不应带 header, got %v", headers)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容 flat resourceUrl 字段。
|
||||
func TestCrossPlatformCoverageParseDriveDownloadInfo_resourceUrlFallback(t *testing.T) {
|
||||
text := `{"resourceUrl":"https://flat.example.com/dl"}`
|
||||
url, _, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if url != "https://flat.example.com/dl" {
|
||||
t.Errorf("url = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容 resourceUrls[].url 数组格式(在 result 包裹下)。
|
||||
func TestCrossPlatformCoverageParseDriveDownloadInfo_resourceUrlsArrayFallback(t *testing.T) {
|
||||
text := `{"result":{"resourceUrls":[{"url":"https://arr.example.com/dl","headers":{}}]}}`
|
||||
url, _, err := parseDriveDownloadInfo(text)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if url != "https://arr.example.com/dl" {
|
||||
t.Errorf("url = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseDriveDownloadInfo_missingURL(t *testing.T) {
|
||||
text := `{"result":{"fileName":"a.txt"}}`
|
||||
if _, _, err := parseDriveDownloadInfo(text); err == nil {
|
||||
t.Fatal("expected error when downloadUrl is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseDriveDownloadInfo_invalidJSON(t *testing.T) {
|
||||
if _, _, err := parseDriveDownloadInfo("not json"); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// drivePartialFailure — pull 部分失败错误:exit=1,stderr 仅保留简短说明
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDrivePartialFailure(t *testing.T) {
|
||||
e := &drivePartialFailure{failed: 2}
|
||||
if e.ExitCode() != 1 {
|
||||
t.Errorf("ExitCode() = %d, want 1", e.ExitCode())
|
||||
}
|
||||
want := "drive pull: 2 file(s) failed"
|
||||
if e.Error() != want || e.RawStderr() != want {
|
||||
t.Errorf("Error()/RawStderr() = %q / %q, want %q", e.Error(), e.RawStderr(), want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// setPullFileTimes 只通过已打开文件描述符更新访问/修改时间,不重新解析文件名。
|
||||
func setPullFileTimes(file *os.File, modified time.Time) error {
|
||||
stamp := unix.NsecToTimeval(modified.UnixNano())
|
||||
return unix.Futimes(int(file.Fd()), []unix.Timeval{stamp, stamp})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// setPullFileTimes 只通过已打开文件句柄更新访问/修改时间,不重新解析文件名。
|
||||
func setPullFileTimes(file *os.File, modified time.Time) error {
|
||||
stamp := windows.NsecToFiletime(modified.UnixNano())
|
||||
return windows.SetFileTime(windows.Handle(file.Fd()), nil, &stamp, &stamp)
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func installPinnedPullSuccess(t *testing.T) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &deps, &Deps{Caller: &driveSyncMockCaller{}, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
_, err := destination.WriteString("remote")
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// replacePullAncestorWithOutsideLink 把已被 pull 固定的 sub 目录移走,
|
||||
// 再在原名处放置指向根外的符号链接,确定性复现远端调用/下载期间的换链。
|
||||
func replacePullAncestorWithOutsideLink(t *testing.T, root, outside string) string {
|
||||
t.Helper()
|
||||
sub := filepath.Join(root, "sub")
|
||||
parked := filepath.Join(root, "sub-pinned")
|
||||
if forcePinnedFallbackForTest {
|
||||
swapPinnedParentIdentity(t, "sub")
|
||||
return sub
|
||||
}
|
||||
if err := os.Rename(sub, parked); err != nil {
|
||||
// Windows 在目录内有已打开的句柄(如 pull 的临时文件)时会锁住该目录,
|
||||
// 移走再换链于该平台物理不可达。退化为等价的父目录身份注入,命中
|
||||
// verifyParent 同一条 fail-closed 分支;原目录未移动,故返回 sub。
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
swapPinnedParentIdentity(t, "sub")
|
||||
return sub
|
||||
}
|
||||
if err := os.Symlink(outside, sub); err != nil {
|
||||
t.Skipf("平台不支持创建符号链接: %v", err)
|
||||
}
|
||||
return parked
|
||||
}
|
||||
|
||||
// replacePullCommandRootWithOutsideLink 把命令根整体移走,并在原名处放置指向根外的
|
||||
// 符号链接,复现「固定后命令根被换掉」的场景。Windows 在根目录持有已打开句柄时会
|
||||
// 锁住该目录,移走于该平台物理不可达,此时退化为等价的根身份注入。
|
||||
func replacePullCommandRootWithOutsideLink(t *testing.T, root, parked, outside string) {
|
||||
t.Helper()
|
||||
if forcePinnedFallbackForTest {
|
||||
swapPinnedRootIdentity(t, root)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(root, parked); err != nil {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
swapPinnedRootIdentity(t, root)
|
||||
return
|
||||
}
|
||||
if err := os.Symlink(outside, root); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runPullTOCTOUCase(t *testing.T, command string, root string) error {
|
||||
t.Helper()
|
||||
rf := &remoteFile{RelPath: "sub/a.txt", FileID: "F"}
|
||||
if command == "pull" {
|
||||
_, err := pullOneFileAtRoot(context.Background(), "", rf, root, rf.RelPath, ifExistsOverwrite)
|
||||
return err
|
||||
}
|
||||
res := &driveSyncResult{}
|
||||
syncPullFile(res, context.Background(), "", rf, root, rf.RelPath, syncDirectionPull)
|
||||
if res.Summary.Failed != 1 || len(res.Items) != 1 {
|
||||
t.Fatalf("sync TOCTOU result = %#v", res)
|
||||
}
|
||||
if res.Items[0].Error == "" {
|
||||
t.Fatal("sync TOCTOU failure must retain the cause")
|
||||
}
|
||||
return &drivePullTestError{message: res.Items[0].Error}
|
||||
}
|
||||
|
||||
type drivePullTestError struct{ message string }
|
||||
|
||||
func (e *drivePullTestError) Error() string { return e.message }
|
||||
|
||||
// download_file 返回期间父目录被换成指向根外的软链时,pull/sync
|
||||
// 必须在创建临时文件和发出 HTTP GET 前失败。
|
||||
func TestCrossPlatformCoverageDrivePullSyncRejectAncestorSwapBeforeTransfer(t *testing.T) {
|
||||
for _, command := range []string{"pull", "sync"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var parked string
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool != "download_file" {
|
||||
t.Fatalf("unexpected MCP tool %q", tool)
|
||||
}
|
||||
parked = replacePullAncestorWithOutsideLink(t, root, outside)
|
||||
return `{"result":{"downloadUrl":"https://oss.example.com/get"}}`, nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
tempCalls, getCalls := 0, 0
|
||||
testseam.Swap(t, &pullCreateTemp, func(root *os.Root) (*os.File, string, error) {
|
||||
tempCalls++
|
||||
return createPinnedPullTemp(root)
|
||||
})
|
||||
testseam.Swap(t, &pullDownloadFile, func(context.Context, string, map[string]string, *os.File) error {
|
||||
getCalls++
|
||||
return nil
|
||||
})
|
||||
|
||||
err := runPullTOCTOUCase(t, command, root)
|
||||
if err == nil {
|
||||
t.Fatal("换链后不应继续下载")
|
||||
}
|
||||
if tempCalls != 0 || getCalls != 0 {
|
||||
t.Fatalf("换链后发生了落盘: temp=%d HTTP_GET=%d", tempCalls, getCalls)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(outside); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("根外目录被写入: entries=%v err=%v", entries, readErr)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(parked); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("已移走的固定目录被写入: entries=%v err=%v", entries, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 传输完成前父目录被换链时,已打开临时文件可以安全清理,
|
||||
// 但最终发布必须在再次核对父目录身份后失败,不得沿新软链写到根外。
|
||||
func TestCrossPlatformCoverageDrivePullSyncRechecksAncestorBeforePublish(t *testing.T) {
|
||||
for _, command := range []string{"pull", "sync"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
return `{"result":{"downloadUrl":"https://oss.example.com/get"}}`, nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
parked := ""
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
parked = replacePullAncestorWithOutsideLink(t, root, outside)
|
||||
return nil
|
||||
})
|
||||
|
||||
err := runPullTOCTOUCase(t, command, root)
|
||||
if err == nil {
|
||||
t.Fatal("发布前换链未被拒绝")
|
||||
}
|
||||
if command == "pull" && !strings.Contains(err.Error(), "目标目录在下载期间被替换") {
|
||||
t.Fatalf("pull error = %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outside, "a.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("根外目标被发布: %v", statErr)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(parked); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("失败后应清理临时文件: entries=%v err=%v", entries, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncKeepBothRejectsAncestorSwapWithoutRollback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
sub := filepath.Join(root, "sub")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("local"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: &driveSyncMockCaller{}, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
parked := ""
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
parked = replacePullAncestorWithOutsideLink(t, root, outside)
|
||||
return nil
|
||||
})
|
||||
|
||||
res := &driveSyncResult{}
|
||||
syncKeepBoth(res, context.Background(), "", &remoteFile{FileID: "FID"}, root, "sub/a.txt", map[string]bool{})
|
||||
|
||||
if res.Summary.Failed != 1 || len(res.Items) != 1 {
|
||||
t.Fatalf("keep-both TOCTOU result = %#v", res)
|
||||
}
|
||||
if !strings.Contains(res.Items[0].Error, "目标目录在下载期间被替换") {
|
||||
t.Fatalf("error = %q", res.Items[0].Error)
|
||||
}
|
||||
if entries, err := os.ReadDir(outside); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("不得沿换链路径触碰根外目录: entries=%v err=%v", entries, err)
|
||||
}
|
||||
kept := filepath.Join(parked, "a.conflict-FID.txt")
|
||||
if content, err := os.ReadFile(kept); err != nil || string(content) != "local" {
|
||||
t.Fatalf("本地保留硬链接应留在原固定目录: content=%q err=%v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePinnedPullFilesystemErrorBranches(t *testing.T) {
|
||||
t.Run("root mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &pullMkdirAll, func(string, os.FileMode) error { return errTestDownload })
|
||||
if _, err := openPinnedPullTarget(filepath.Join(t.TempDir(), "root"), "a.txt"); err == nil {
|
||||
t.Fatal("expected root mkdir failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("open root", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
testseam.Swap(t, &pullOpenRoot, func(string) (*os.Root, error) { return nil, errTestDownload })
|
||||
if _, err := openPinnedPullTarget(root, "a.txt"); err == nil {
|
||||
t.Fatal("expected root open failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("relative path", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, relFn := range map[string]func(string, string) (string, error){
|
||||
"error": func(string, string) (string, error) { return "", errTestDownload },
|
||||
"escape": func(string, string) (string, error) { return "../escape", nil },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
testseam.Swap(t, &pullRelPath, relFn)
|
||||
if _, err := openPinnedPullTarget(root, "a.txt"); err == nil {
|
||||
t.Fatal("expected relative-path failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parent mkdir", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
testseam.Swap(t, &pullRootMkdir, func(*os.Root, string, os.FileMode) error { return errTestDownload })
|
||||
if _, err := openPinnedPullTarget(root, "sub/a.txt"); err == nil {
|
||||
t.Fatal("expected parent mkdir failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parent mkdir success", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target, err := openPinnedPullTarget(root, "sub/a.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target.close()
|
||||
if info, err := os.Lstat(filepath.Join(root, "sub")); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatalf("created parent is not a real directory: info=%v err=%v", info, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("open root clone", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
testseam.Swap(t, &pullOpenParentRoot, func(*os.Root, string) (*os.Root, error) { return nil, errTestDownload })
|
||||
if _, err := openPinnedPullTarget(root, "sub/a.txt"); err == nil {
|
||||
t.Fatal("expected root clone failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("open parent segment", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &pullOpenParentRoot, func(root *os.Root, name string) (*os.Root, error) {
|
||||
if name == "sub" {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.OpenRoot(name)
|
||||
})
|
||||
if _, err := openPinnedPullTarget(root, "sub/a.txt"); err == nil {
|
||||
t.Fatal("expected parent segment open failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lstat root parent", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
if _, err := openPinnedPullTarget(root, "a.txt"); err == nil {
|
||||
t.Fatal("expected root parent lstat failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lstat parent segment", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "sub" {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
if _, err := openPinnedPullTarget(root, "sub/a.txt"); err == nil {
|
||||
t.Fatal("expected parent segment lstat failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stat parent", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
testseam.Swap(t, &pullRootStat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
if _, err := openPinnedPullTarget(root, "a.txt"); err == nil {
|
||||
t.Fatal("expected parent stat failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parent changes while target opens", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
calls := 0
|
||||
testseam.Swap(t, &pullRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, errTestDownload
|
||||
}
|
||||
return root.Stat(name)
|
||||
})
|
||||
if _, err := openPinnedPullTarget(root, "a.txt"); err == nil {
|
||||
t.Fatal("expected construction-time parent identity failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lstat target", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target, err := openPinnedPullTarget(root, "a.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer target.close()
|
||||
testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) { return nil, errTestDownload })
|
||||
if _, err := target.regularTargetInfo(); err == nil {
|
||||
t.Fatal("expected target lstat failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create temp root error and collision exhaustion", func(t *testing.T) {
|
||||
closed, err := os.OpenRoot(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = closed.Close()
|
||||
if _, _, err := createPinnedPullTemp(closed); err == nil {
|
||||
t.Fatal("expected closed root failure")
|
||||
}
|
||||
|
||||
rootDir := t.TempDir()
|
||||
root, err := os.OpenRoot(rootDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
const collision = "collision"
|
||||
if err := os.WriteFile(filepath.Join(rootDir, ".dws-pull-"+collision), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &pullTempName, func() string { return collision })
|
||||
if _, _, err := createPinnedPullTemp(root); err == nil {
|
||||
t.Fatal("expected collision exhaustion")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePinnedPullPublicationErrorBranches(t *testing.T) {
|
||||
call := func(t *testing.T, root string) error {
|
||||
t.Helper()
|
||||
_, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite)
|
||||
return err
|
||||
}
|
||||
|
||||
t.Run("temp stat", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullCreateTemp, func(root *os.Root) (*os.File, string, error) {
|
||||
file, name, err := createPinnedPullTemp(root)
|
||||
if err == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
return file, name, err
|
||||
})
|
||||
if err := call(t, root); err == nil || !strings.Contains(err.Error(), "读取临时文件身份失败") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("temp sync", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullSyncTemp, func(*os.File) error { return errTestDownload })
|
||||
if err := call(t, root); err == nil || !strings.Contains(err.Error(), "同步临时文件失败") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("temp close", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullCloseTemp, func(*os.File) error { return errTestDownload })
|
||||
if err := call(t, root); err == nil || !strings.Contains(err.Error(), "关闭临时文件失败") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("temp identity", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
other := filepath.Join(t.TempDir(), "other")
|
||||
if err := os.WriteFile(other, []byte("other"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherInfo, err := os.Lstat(other)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
downloaded := false
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
downloaded = true
|
||||
_, err := destination.WriteString("remote")
|
||||
return err
|
||||
})
|
||||
testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if downloaded && strings.HasPrefix(name, ".dws-pull-") {
|
||||
return otherInfo, nil
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
if err := call(t, root); err == nil || !strings.Contains(err.Error(), "临时文件在下载期间被替换") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("target becomes directory", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Mkdir(filepath.Join(root, "a.txt"), 0o755)
|
||||
})
|
||||
if err := call(t, root); err == nil || !strings.Contains(err.Error(), "不是常规文件") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDefaultPullDownloadFileBranches(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("X-Test") != "yes" {
|
||||
t.Errorf("header = %q", request.Header.Get("X-Test"))
|
||||
}
|
||||
if request.URL.Path == "/status" {
|
||||
http.Error(w, "denied", http.StatusTeapot)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "payload")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
destination, err := os.Create(filepath.Join(t.TempDir(), "payload"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer destination.Close()
|
||||
if err := defaultPullDownloadFile(context.Background(), ":", nil, destination); err == nil {
|
||||
t.Fatal("expected invalid request URL")
|
||||
}
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := defaultPullDownloadFile(cancelled, server.URL, nil, destination); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled error = %v", err)
|
||||
}
|
||||
if err := defaultPullDownloadFile(context.Background(), server.URL+"/status", map[string]string{"X-Test": "yes"}, destination); err == nil {
|
||||
t.Fatal("expected HTTP status failure")
|
||||
}
|
||||
if err := defaultPullDownloadFile(context.Background(), server.URL+"/ok", map[string]string{"X-Test": "yes"}, destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
closed, err := os.Create(filepath.Join(t.TempDir(), "closed"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = closed.Close()
|
||||
if err := defaultPullDownloadFile(context.Background(), server.URL+"/ok", map[string]string{"X-Test": "yes"}, closed); err == nil {
|
||||
t.Fatal("expected destination copy failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncKeepBothLinkRemainsPinnedWhenParentMoves(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
sub := filepath.Join(root, "sub")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("local"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range map[string]string{
|
||||
"a.txt": "attacker-destination",
|
||||
"a.conflict-FID.txt": "attacker-source",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(outside, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: &driveSyncMockCaller{}, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
links := 0
|
||||
parked := ""
|
||||
testseam.Swap(t, &syncRootLink, func(parent *os.Root, oldName, newName string) error {
|
||||
links++
|
||||
// reserve 已完成 verifyParent;在真正 no-clobber Link 前换链,验证操作
|
||||
// 仍只落在已固定的 parentRoot,而不会沿新软链进入根外目录。
|
||||
parked = replacePullAncestorWithOutsideLink(t, root, outside)
|
||||
return parent.Link(oldName, newName)
|
||||
})
|
||||
|
||||
res := &driveSyncResult{}
|
||||
syncKeepBoth(res, context.Background(), "", &remoteFile{FileID: "FID"}, root, "sub/a.txt", map[string]bool{})
|
||||
if res.Summary.Failed != 1 || links != 1 {
|
||||
t.Fatalf("result=%#v links=%d", res, links)
|
||||
}
|
||||
for _, name := range []string{"a.txt", "a.conflict-FID.txt"} {
|
||||
if content, err := os.ReadFile(filepath.Join(parked, name)); err != nil || string(content) != "local" {
|
||||
t.Fatalf("固定目录内的 %s 未安全保留: content=%q err=%v", name, content, err)
|
||||
}
|
||||
}
|
||||
for name, want := range map[string]string{
|
||||
"a.txt": "attacker-destination",
|
||||
"a.conflict-FID.txt": "attacker-source",
|
||||
} {
|
||||
if content, err := os.ReadFile(filepath.Join(outside, name)); err != nil || string(content) != want {
|
||||
t.Fatalf("根外 %s 被硬链接触碰: content=%q err=%v", name, content, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncKeepBothDetectsCandidateMutationDuringTransfer(t *testing.T) {
|
||||
for _, mutation := range []string{"deleted", "replaced", "modified"} {
|
||||
t.Run(mutation, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("local"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidate := filepath.Join(root, "a.conflict-FID.txt")
|
||||
testseam.Swap(t, &deps, &Deps{Caller: &driveSyncMockCaller{}, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
switch mutation {
|
||||
case "deleted":
|
||||
return os.Remove(candidate)
|
||||
case "replaced":
|
||||
if err := os.Remove(candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(candidate, []byte("attacker"), 0o600)
|
||||
default:
|
||||
return os.WriteFile(candidate, []byte("modified-local-version"), 0o600)
|
||||
}
|
||||
})
|
||||
|
||||
res := &driveSyncResult{}
|
||||
syncKeepBoth(res, context.Background(), "", &remoteFile{FileID: "FID"}, root, "a.txt", map[string]bool{})
|
||||
if res.Summary.Failed != 1 || res.Summary.Pulled != 0 || len(res.Items) != 1 {
|
||||
t.Fatalf("result=%#v", res)
|
||||
}
|
||||
if res.Items[0].Action != syncActionFailed || !strings.Contains(res.Items[0].Error, "本地保留版本在传输期间") {
|
||||
t.Fatalf("candidate mutation must be failed, got %#v", res.Items)
|
||||
}
|
||||
if content, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(content) != "remote" {
|
||||
t.Fatalf("remote publish result changed: content=%q err=%v", content, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullCommandPinsRootAcrossAllFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
caller := pullListingCaller(
|
||||
`{"name":"a.txt","type":"file","fileId":"A"},` +
|
||||
`{"name":"b.txt","type":"file","fileId":"B"}`)
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"})
|
||||
|
||||
calls := 0
|
||||
parked := filepath.Join(t.TempDir(), "original-root")
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
calls++
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
if calls == 1 {
|
||||
replacePullCommandRootWithOutsideLink(t, root, parked, outside)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
cmd := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT", "if-exists": "overwrite"})
|
||||
if err := runDrivePull(cmd, nil); err == nil {
|
||||
t.Fatal("root replacement must fail the command")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("second file must fail before transfer when command root changed: calls=%d", calls)
|
||||
}
|
||||
if entries, err := os.ReadDir(outside); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("outside root was touched: entries=%v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullPlansExistingRootBeforeRemoteRead(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "mirror")
|
||||
outside := t.TempDir()
|
||||
parked := filepath.Join(parent, "mirror-pinned")
|
||||
if err := os.Mkdir(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
t.Fatalf("root replacement must stop before %s", tool)
|
||||
}
|
||||
replacePullCommandRootWithOutsideLink(t, root, parked, outside)
|
||||
return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}]}}`, nil
|
||||
}}
|
||||
getCalls := 0
|
||||
testseam.Swap(t, &pullDownloadFile, func(context.Context, string, map[string]string, *os.File) error {
|
||||
getCalls++
|
||||
return nil
|
||||
})
|
||||
|
||||
err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "overwrite")
|
||||
if err == nil || !strings.Contains(err.Error(), "本地根目录在同步期间被替换") {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
if len(caller.callsFor("download_file")) != 0 || getCalls != 0 {
|
||||
t.Fatalf("root replacement reached transfer: MCP=%d GET=%d", len(caller.callsFor("download_file")), getCalls)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(outside); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("outside root was touched: entries=%v err=%v", entries, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullPlansMissingRootBeforeRemoteRead(t *testing.T) {
|
||||
for _, takeover := range []string{"directory", "symlink"} {
|
||||
t.Run(takeover, func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "missing", "mirror")
|
||||
outside := t.TempDir()
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
t.Fatalf("missing-root takeover must stop before %s", tool)
|
||||
}
|
||||
if takeover == "directory" {
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := os.MkdirAll(filepath.Dir(root), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, root); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}]}}`, nil
|
||||
}}
|
||||
getCalls := 0
|
||||
testseam.Swap(t, &pullDownloadFile, func(context.Context, string, map[string]string, *os.File) error {
|
||||
getCalls++
|
||||
return nil
|
||||
})
|
||||
|
||||
err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", "overwrite")
|
||||
if err == nil || !strings.Contains(err.Error(), "在远端读取期间被占用") {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
if len(caller.callsFor("download_file")) != 0 || getCalls != 0 {
|
||||
t.Fatalf("missing-root takeover reached transfer: MCP=%d GET=%d", len(caller.callsFor("download_file")), getCalls)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(outside); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("outside root was touched: entries=%v err=%v", entries, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullDryRunDoesNotMaterializeMissingRoot(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "missing", "mirror")
|
||||
caller := &driveScriptCaller{dryRun: true, reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
t.Fatalf("dry-run must stop before %s", tool)
|
||||
}
|
||||
return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}]}}`, nil
|
||||
}}
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(root); !os.IsNotExist(err) {
|
||||
t.Fatalf("dry-run materialized missing root: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncCommandPinsRootAcrossAllFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
caller := &driveSyncMockCaller{listJSON: `{"result":{"items":[` +
|
||||
`{"name":"a.txt","type":"file","fileId":"A"},` +
|
||||
`{"name":"b.txt","type":"file","fileId":"B"}` +
|
||||
`],"nextToken":""}}`}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil })
|
||||
|
||||
calls := 0
|
||||
parked := filepath.Join(t.TempDir(), "original-root")
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
calls++
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
if calls == 1 {
|
||||
replacePullCommandRootWithOutsideLink(t, root, parked, outside)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
cmd := findDriveSubcommand(t, "sync")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": root, "remote-folder": "ROOT"})
|
||||
if err := runDriveSync(cmd, nil); err == nil {
|
||||
t.Fatal("root replacement must fail sync")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("second sync file must fail before transfer when command root changed: calls=%d", calls)
|
||||
}
|
||||
if entries, err := os.ReadDir(outside); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("outside root was touched: entries=%v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullReappliesSkipAndSmartAtPublish(t *testing.T) {
|
||||
for _, policy := range []string{ifExistsSkip, ifExistsSmart} {
|
||||
t.Run(policy, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
if _, err := destination.WriteString("remote"); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(root, "a.txt"), []byte("concurrent"), 0o600)
|
||||
})
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", policy)
|
||||
if err != nil || action != pullActionSkipped {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
if content, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(content) != "concurrent" {
|
||||
t.Fatalf("concurrent target was overwritten: content=%q err=%v", content, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePullPublicationIdentityAndLinkFailures(t *testing.T) {
|
||||
t.Run("link failure without target is not skipped", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
testseam.Swap(t, &pullLink, func(*os.Root, string, string) error { return errTestDownload })
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsSkip)
|
||||
if err == nil || action != pullActionFailed || !strings.Contains(err.Error(), "发布目标文件失败") {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("post publish mismatch preserves unknown terminal", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
other := filepath.Join(t.TempDir(), "other")
|
||||
if err := os.WriteFile(other, []byte("other"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherInfo, _ := os.Lstat(other)
|
||||
published := false
|
||||
testseam.Swap(t, &pullRename, func(root *os.Root, oldName, newName string) error {
|
||||
if err := root.Rename(oldName, newName); err != nil {
|
||||
return err
|
||||
}
|
||||
published = true
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if published && name == "a.txt" {
|
||||
return otherInfo, nil
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", &remoteFile{FileID: "F"}, root, "a.txt", ifExistsOverwrite)
|
||||
if err == nil || action != pullActionFailed {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
if content, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(content) != "remote" {
|
||||
t.Fatalf("未知 terminal entry 应保留: content=%q err=%v", content, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// os.Root 只保证路径不能逃逸根目录,仍会跟随指向根内目录的相对软链接。若 sub 在
|
||||
// Lstat 与 OpenRoot 之间被换成 sub -> victim,pull/sync/keep-both 都必须在任何远端
|
||||
// 读取或本地写入前拒绝,不能把 remote sub/a.txt 重定向到 victim/a.txt。
|
||||
func TestCrossPlatformCoverageDrivePullSyncKeepBothRejectRootInternalRedirect(t *testing.T) {
|
||||
for _, redirect := range []string{"symlink", "directory"} {
|
||||
for _, command := range []string{"pull", "sync", "keep-both"} {
|
||||
t.Run(redirect+"/"+command, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sub := filepath.Join(root, "sub")
|
||||
parked := filepath.Join(root, "sub-pinned")
|
||||
victim := filepath.Join(root, "victim")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(victim, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(sub, "a.txt"), "local")
|
||||
mustWrite(t, filepath.Join(victim, "a.txt"), "victim")
|
||||
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool != "download_file" {
|
||||
t.Fatalf("unexpected tool %q", tool)
|
||||
}
|
||||
return `{"result":{"downloadUrl":"https://oss.example.com/get"}}`, nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
testseam.Swap(t, &pullDownloadFile, func(_ context.Context, _ string, _ map[string]string, destination *os.File) error {
|
||||
_, err := destination.WriteString("remote")
|
||||
return err
|
||||
})
|
||||
|
||||
redirectedVictim := filepath.Join(victim, "a.txt")
|
||||
swapped := false
|
||||
testseam.Swap(t, &pullOpenParentRoot, func(parent *os.Root, name string) (*os.Root, error) {
|
||||
if name == "sub" && !swapped {
|
||||
swapped = true
|
||||
if err := os.Rename(sub, parked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if redirect == "symlink" {
|
||||
if err := os.Symlink("victim", sub); err != nil {
|
||||
t.Skipf("平台不支持创建符号链接: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := os.Rename(victim, sub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirectedVictim = filepath.Join(sub, "a.txt")
|
||||
}
|
||||
}
|
||||
return parent.OpenRoot(name)
|
||||
})
|
||||
|
||||
rf := &remoteFile{RelPath: "sub/a.txt", FileID: "F"}
|
||||
switch command {
|
||||
case "pull":
|
||||
action, err := pullOneFileAtRoot(context.Background(), "", rf, root, rf.RelPath, ifExistsOverwrite)
|
||||
if err == nil || action != pullActionFailed {
|
||||
t.Fatalf("action=%q err=%v", action, err)
|
||||
}
|
||||
case "sync":
|
||||
res := &driveSyncResult{}
|
||||
syncPullFile(res, context.Background(), "", rf, root, rf.RelPath, syncDirectionPull)
|
||||
if res.Summary.Failed != 1 || res.Summary.Pulled != 0 {
|
||||
t.Fatalf("sync result=%#v", res)
|
||||
}
|
||||
default:
|
||||
res := &driveSyncResult{}
|
||||
syncKeepBoth(res, context.Background(), "", rf, root, rf.RelPath, map[string]bool{})
|
||||
if res.Summary.Failed != 1 || res.Summary.Pulled != 0 {
|
||||
t.Fatalf("keep-both result=%#v", res)
|
||||
}
|
||||
}
|
||||
|
||||
if !swapped {
|
||||
t.Fatal("test did not install the root-internal redirect")
|
||||
}
|
||||
if got := len(caller.callsFor("download_file")); got != 0 {
|
||||
t.Fatalf("root-internal redirect reached download_file: %d", got)
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
filepath.Join(parked, "a.txt"): "local",
|
||||
redirectedVictim: "victim",
|
||||
} {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil || string(content) != want {
|
||||
t.Fatalf("%s changed: content=%q err=%v", path, content, err)
|
||||
}
|
||||
}
|
||||
if matches, err := filepath.Glob(filepath.Join(filepath.Dir(redirectedVictim), "a.conflict-*.txt")); err != nil || len(matches) != 0 {
|
||||
t.Fatalf("keep-both wrote into redirected victim: matches=%v err=%v", matches, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// ==========================================================
|
||||
// drive push — 把本地文件夹镜像到钉盘(本地 → Drive)
|
||||
// ==========================================================
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws drive push — 把本地目录单向、文件级镜像到钉盘(本地 → Drive)
|
||||
//
|
||||
// 递归遍历 --local-folder 下所有常规文件与子目录(含空目录),按相对路径在
|
||||
// --remote-folder 指向的钉盘文件夹里新建/覆盖/跳过。已存在的远端目录复用其
|
||||
// fileId、不重建;缺失的目录按需 create_folder。文件按 --if-exists 决定
|
||||
// skip / smart / overwrite。summary.failed > 0 时以非零退出码退出(结构化
|
||||
// summary + items 仍打印在 stdout 上)。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// push 动作分类。
|
||||
const (
|
||||
pushActionUploaded = "uploaded" // 新建上传
|
||||
pushActionOverwritten = "overwritten" // 覆盖已存在的远端文件
|
||||
pushActionSkipped = "skipped" // 按 --if-exists 跳过
|
||||
pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded)
|
||||
pushActionFailed = "failed"
|
||||
)
|
||||
|
||||
// localPushFile 描述一个待推送的本地常规文件。
|
||||
type localPushFile struct {
|
||||
RelPath string
|
||||
AbsPath string
|
||||
ModTimeMillis int64
|
||||
Size int64
|
||||
scanInfo os.FileInfo
|
||||
}
|
||||
|
||||
// pushPutOpenedFile 把已由固定 os.Root 打开的本地文件句柄上传到 OSS。生产 push/sync
|
||||
// 不再把可被并发换链的绝对路径交给 HTTP 层重新打开;测试必须通过 testseam.Swap 替换。
|
||||
var (
|
||||
pushPutOpenedFile = defaultPushPutOpenedFile
|
||||
pushStatOpenedFile = func(file *os.File) (os.FileInfo, error) {
|
||||
return file.Stat()
|
||||
}
|
||||
pushVerifyPinnedSourceRoot = func(root *pinnedPullRoot) error {
|
||||
return root.verify()
|
||||
}
|
||||
pushMD5OpenedFile = func(file *os.File) (string, error) {
|
||||
hash := md5.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
)
|
||||
|
||||
// drivePushItem 是输出 items[] 中每个条目的明细。
|
||||
type drivePushItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Action string `json:"action"`
|
||||
SizeBytes *int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。
|
||||
type drivePushSummary struct {
|
||||
Uploaded int `json:"uploaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Aborted bool `json:"aborted"`
|
||||
}
|
||||
|
||||
type drivePushResult struct {
|
||||
Summary drivePushSummary `json:"summary"`
|
||||
Items []drivePushItem `json:"items"`
|
||||
}
|
||||
|
||||
type drivePushDryRunResult struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Executed bool `json:"executed"`
|
||||
PreviewKind string `json:"preview_kind"`
|
||||
Operation string `json:"operation"`
|
||||
IfExists string `json:"if_exists"`
|
||||
Plan drivePushResult `json:"plan"`
|
||||
}
|
||||
|
||||
// drivePushFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout,
|
||||
// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明。
|
||||
type drivePushFailure struct{ failed int }
|
||||
|
||||
func (e *drivePushFailure) Error() string {
|
||||
return fmt.Sprintf("drive push: %d file(s) failed", e.failed)
|
||||
}
|
||||
func (e *drivePushFailure) RawStderr() string { return e.Error() }
|
||||
func (e *drivePushFailure) ExitCode() int { return 1 }
|
||||
|
||||
func runDrivePush(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil {
|
||||
return err
|
||||
}
|
||||
localDir := mustGetFlag(cmd, "local-folder")
|
||||
remoteDirID := mustGetFlag(cmd, "remote-folder")
|
||||
spaceID := mustGetFlag(cmd, "space-id")
|
||||
|
||||
// push 默认 skip(安全,只新增不覆盖)。
|
||||
ifExists, _ := cmd.Flags().GetString("if-exists")
|
||||
if ifExists == "" {
|
||||
ifExists = ifExistsSkip
|
||||
}
|
||||
switch ifExists {
|
||||
case ifExistsSkip, ifExistsSmart, ifExistsOverwrite:
|
||||
default:
|
||||
return fmt.Errorf("--if-exists 取值非法: %s(可选 skip|smart|overwrite)", ifExists)
|
||||
}
|
||||
|
||||
absDir, err := validateLocalDirAbs(localDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pinnedRoot, err := openExistingPinnedPullRoot(absDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("扫描本地目录失败: %w", err)
|
||||
}
|
||||
defer pinnedRoot.close()
|
||||
|
||||
localDirs, localFiles, err := runDriveMirrorWalkLocalForPushPinned(pinnedRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("扫描本地目录失败: %w", err)
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
// 远端现状:已存在的文件(用于 --if-exists 判断)与目录(rel_path → fileId,用于复用/定位父目录)。
|
||||
remoteFiles, remoteFolders, err := fetchRemoteTreeForPush(ctx, spaceID, remoteDirID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
preflight := buildMirrorPreflight(localDirs, localFiles, remoteFiles, remoteFolders)
|
||||
|
||||
res := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))}
|
||||
if deps.Caller.DryRun() {
|
||||
return printDrivePushDryRunWithPreflight(ifExists, remoteFiles, remoteFolders, localDirs, localFiles, preflight)
|
||||
}
|
||||
if len(preflight) > 0 {
|
||||
appendDrivePushPreflightFailures(&res, preflight)
|
||||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||||
return perr
|
||||
}
|
||||
return &drivePushFailure{failed: res.Summary.Failed}
|
||||
}
|
||||
// 本地扫描与远端读取都可能耗时。首个远端写之前确认命令行路径仍指向最初固定
|
||||
// 的根;若根已被移走或替换,整批中止,不能把替代目录的数据当成扫描快照上传。
|
||||
if err := pinnedRoot.verify(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 第一阶段:按需创建远端目录(浅层在前,保证父目录先于子目录存在)。
|
||||
for _, dir := range localDirs {
|
||||
if _, ok := remoteFolders[dir]; ok {
|
||||
continue // 远端已存在,复用 fileId,不留痕
|
||||
}
|
||||
parentRel, name := splitRel(dir)
|
||||
parentID, ok := remoteFolders[parentRel]
|
||||
if !ok || parentID == "" {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
if err := pinnedRoot.verify(); err != nil {
|
||||
return err
|
||||
}
|
||||
fid, cerr := pushCreateFolder(ctx, spaceID, parentID, name)
|
||||
if cerr != nil || fid == "" {
|
||||
res.Summary.Failed++
|
||||
msg := "create_folder 未返回 fileId"
|
||||
if cerr != nil {
|
||||
msg = cerr.Error()
|
||||
}
|
||||
res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: msg})
|
||||
continue
|
||||
}
|
||||
remoteFolders[dir] = fid
|
||||
res.Items = append(res.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
|
||||
}
|
||||
|
||||
// 第二阶段:上传/覆盖/跳过文件。
|
||||
for i := range localFiles {
|
||||
lf := localFiles[i]
|
||||
size := lf.Size
|
||||
parentRel, name := splitRel(lf.RelPath)
|
||||
parentID, ok := remoteFolders[parentRel]
|
||||
if !ok || parentID == "" {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
|
||||
rf, exists := remoteFiles[lf.RelPath]
|
||||
action := pushActionUploaded
|
||||
overwriteID := ""
|
||||
if exists {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
res.Summary.Skipped++
|
||||
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionSkipped, SizeBytes: &size})
|
||||
continue
|
||||
case ifExistsSmart:
|
||||
// 远端时间可信且已 ≥ 本地 → 跳过;否则走覆盖路径。
|
||||
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
|
||||
res.Summary.Skipped++
|
||||
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionSkipped, SizeBytes: &size})
|
||||
continue
|
||||
}
|
||||
action = pushActionOverwritten
|
||||
case ifExistsOverwrite:
|
||||
action = pushActionOverwritten
|
||||
}
|
||||
// 覆盖分支必须走覆盖上传(传 overwriteFileId、不传 parentId),
|
||||
// 否则会在同目录新建重名副本而非原地覆盖。
|
||||
if action == pushActionOverwritten {
|
||||
overwriteID = rf.FileID
|
||||
}
|
||||
}
|
||||
|
||||
if err := pushUploadFilePinned(ctx, spaceID, parentID, overwriteID, name, pinnedRoot, lf); err != nil {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
res.Summary.Uploaded++ // uploaded 同时统计新建与覆盖
|
||||
res.Items = append(res.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
|
||||
}
|
||||
|
||||
// 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。
|
||||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||||
return perr
|
||||
}
|
||||
if res.Summary.Failed > 0 {
|
||||
return &drivePushFailure{failed: res.Summary.Failed}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchRemoteTreeForPush 递归拉取 rootID 下的远端现状:files(rel_path → *remoteFile,
|
||||
// 用于 --if-exists 判断)与 folders(rel_path → fileId,rel_path "" 即 rootID 本身)。
|
||||
func fetchRemoteTreeForPush(ctx context.Context, spaceID, rootID string) (map[string]*remoteFile, map[string]string, error) {
|
||||
files := make(map[string]*remoteFile)
|
||||
folders := map[string]string{"": rootID}
|
||||
occupied := make(map[string]string)
|
||||
if err := walkRemoteForPush(ctx, spaceID, rootID, "", files, folders, occupied, 0); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return files, folders, nil
|
||||
}
|
||||
|
||||
func walkRemoteForPush(ctx context.Context, spaceID, parentID, relBase string, files map[string]*remoteFile, folders map[string]string, occupied map[string]string, depth int) error {
|
||||
if depth > remoteMaxDepth {
|
||||
return fmt.Errorf("drive 目录层级超过 %d 层,疑似循环引用,已中止", remoteMaxDepth)
|
||||
}
|
||||
nextToken := ""
|
||||
seenTokens := make(map[string]struct{})
|
||||
for {
|
||||
args := map[string]any{"maxResults": float64(driveListPageSize)}
|
||||
if spaceID != "" {
|
||||
args["spaceId"] = spaceID
|
||||
}
|
||||
if parentID != "" {
|
||||
args["parentId"] = parentID
|
||||
}
|
||||
if nextToken != "" {
|
||||
args["nextToken"] = nextToken
|
||||
}
|
||||
|
||||
text, err := callDriveListFiles(ctx, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, token, err := parseDriveList(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
name, included, err := remoteMirrorEntryName(it, relBase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !included {
|
||||
continue
|
||||
}
|
||||
childRel := name
|
||||
if relBase != "" {
|
||||
childRel = relBase + "/" + name
|
||||
}
|
||||
if it.isFolder() {
|
||||
if err := claimRemotePath(occupied, childRel, "目录"); err != nil {
|
||||
return err
|
||||
}
|
||||
folderID := it.id()
|
||||
if folderID == "" {
|
||||
return fmt.Errorf("远端目录 %q 缺少目录 ID,已中止递归", childRel)
|
||||
}
|
||||
folders[childRel] = folderID
|
||||
if err := walkRemoteForPush(ctx, spaceID, folderID, childRel, files, folders, occupied, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 非文件类型已由 remoteMirrorEntryName 过滤;目录在上方完成递归后返回。
|
||||
if err := claimRemotePath(occupied, childRel, "文件"); err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := it.id()
|
||||
if fileID == "" {
|
||||
return fmt.Errorf("远端文件 %q 缺少文件 ID,已中止遍历", childRel)
|
||||
}
|
||||
modMillis, modValid := it.modifiedMillis()
|
||||
files[childRel] = &remoteFile{
|
||||
RelPath: childRel,
|
||||
FileID: fileID,
|
||||
Hash: it.hash(),
|
||||
ModifiedTime: modMillis,
|
||||
ModifiedTimeValid: modValid,
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
break
|
||||
}
|
||||
if _, exists := seenTokens[token]; exists {
|
||||
return fmt.Errorf("远端目录 %q 的分页 token 重复,疑似分页循环,已中止", relBase)
|
||||
}
|
||||
seenTokens[token] = struct{}{}
|
||||
nextToken = token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printDrivePushDryRun(ifExists string, remoteFiles map[string]*remoteFile, remoteFolders map[string]string, localDirs []string, localFiles []localPushFile) error {
|
||||
return printDrivePushDryRunWithPreflight(ifExists, remoteFiles, remoteFolders, localDirs, localFiles,
|
||||
buildMirrorPreflight(localDirs, localFiles, remoteFiles, remoteFolders))
|
||||
}
|
||||
|
||||
func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*remoteFile, remoteFolders map[string]string, localDirs []string, localFiles []localPushFile, preflight map[string]string) error {
|
||||
plan := drivePushResult{Items: make([]drivePushItem, 0, len(localDirs)+len(localFiles))}
|
||||
if len(preflight) > 0 {
|
||||
appendDrivePushPreflightFailures(&plan, preflight)
|
||||
return deps.Out.PrintJSON(drivePushDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive push",
|
||||
IfExists: ifExists, Plan: plan,
|
||||
})
|
||||
}
|
||||
plannedFolders := make(map[string]string, len(remoteFolders)+len(localDirs))
|
||||
for rel, fileID := range remoteFolders {
|
||||
plannedFolders[rel] = fileID
|
||||
}
|
||||
for _, dir := range localDirs {
|
||||
if _, ok := plannedFolders[dir]; ok {
|
||||
continue
|
||||
}
|
||||
parentRel, _ := splitRel(dir)
|
||||
if plannedFolders[parentRel] == "" {
|
||||
plan.Summary.Failed++
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFailed, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
plannedFolders[dir] = "dry-run-planned-folder"
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
|
||||
}
|
||||
for _, lf := range localFiles {
|
||||
size := lf.Size
|
||||
parentRel, _ := splitRel(lf.RelPath)
|
||||
if plannedFolders[parentRel] == "" {
|
||||
plan.Summary.Failed++
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
action := pushActionUploaded
|
||||
if rf, exists := remoteFiles[lf.RelPath]; exists {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
action = pushActionSkipped
|
||||
case ifExistsSmart:
|
||||
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
|
||||
action = pushActionSkipped
|
||||
} else {
|
||||
action = pushActionOverwritten
|
||||
}
|
||||
case ifExistsOverwrite:
|
||||
action = pushActionOverwritten
|
||||
}
|
||||
}
|
||||
if action == pushActionSkipped {
|
||||
plan.Summary.Skipped++
|
||||
} else {
|
||||
plan.Summary.Uploaded++
|
||||
}
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
|
||||
}
|
||||
return deps.Out.PrintJSON(drivePushDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive push",
|
||||
IfExists: ifExists, Plan: plan,
|
||||
})
|
||||
}
|
||||
|
||||
func appendDrivePushPreflightFailures(res *drivePushResult, preflight map[string]string) {
|
||||
res.Summary.Aborted = true
|
||||
for _, rel := range mirrorPreflightFailureRels(preflight) {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, drivePushItem{RelPath: rel, Action: pushActionFailed, Error: preflight[rel]})
|
||||
}
|
||||
}
|
||||
|
||||
// pushTypeConflictError 在任何写操作或 dry-run 成功预览之前,拒绝同一路径下
|
||||
// “本地目录 ↔ 远端文件”或“本地文件 ↔ 远端目录”的类型冲突。
|
||||
func pushTypeConflictError(rel string, localIsFolder bool, remoteFiles map[string]*remoteFile, remoteFolders map[string]string) string {
|
||||
if localIsFolder {
|
||||
if _, exists := remoteFiles[rel]; exists {
|
||||
return "远端同路径已存在文件,无法创建目录"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if _, exists := remoteFolders[rel]; exists {
|
||||
return "远端同路径已存在目录,无法上传文件"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// walkLocalForPush 遍历本地根目录,返回所有子目录 rel_path(不含根本身,浅层在前)
|
||||
// 与所有常规文件。dirs 升序排序保证父目录先于子目录被创建。
|
||||
func walkLocalForPush(root string) ([]string, []localPushFile, error) {
|
||||
pinned, err := openExistingPinnedPullRoot(root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer pinned.close()
|
||||
return walkLocalForPushPinned(pinned)
|
||||
}
|
||||
|
||||
// 文件系统遍历与命令入口通过显式 seam 暴露仅用于确定性覆盖 I/O 失败;测试必须用
|
||||
// testseam.Swap 替换,生产仍直接使用 fs.WalkDir / walkLocalForPushPinned。
|
||||
var (
|
||||
walkPinnedLocalFS = fs.WalkDir
|
||||
runDriveMirrorWalkLocalForPushPinned = walkLocalForPushPinned
|
||||
)
|
||||
|
||||
// walkLocalForPushPinned 从命令开始时固定的同一个 os.Root/FS 扫描本地树。即使调用方
|
||||
// 给出的路径随后被替换成软链或另一目录,遍历也只会读取原目录树。
|
||||
func walkLocalForPushPinned(root *pinnedPullRoot) ([]string, []localPushFile, error) {
|
||||
if err := root.verify(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var dirs []string
|
||||
var files []localPushFile
|
||||
err := walkPinnedLocalFS(root.root.FS(), ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "." {
|
||||
return nil
|
||||
}
|
||||
relSlash := filepath.ToSlash(path)
|
||||
if d.IsDir() {
|
||||
dirs = append(dirs, relSlash)
|
||||
return nil
|
||||
}
|
||||
info, infoErr := d.Info()
|
||||
if infoErr != nil {
|
||||
return infoErr
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
files = append(files, localPushFile{
|
||||
RelPath: relSlash, AbsPath: filepath.Join(root.absDir, filepath.FromSlash(relSlash)),
|
||||
ModTimeMillis: info.ModTime().UnixMilli(), Size: info.Size(), scanInfo: info,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := root.verify(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 字典序即浅层在前("a" < "a/b" < "a/b/c"),父目录先于子目录。
|
||||
sort.Strings(dirs)
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].RelPath < files[j].RelPath })
|
||||
return dirs, files, nil
|
||||
}
|
||||
|
||||
// judgePinnedPushFileMatch 与 judgeFileMatch 的判定保持一致,但 exact 模式只从固定
|
||||
// os.Root 打开的句柄计算 MD5,绝不按可被换链的 AbsPath 重开文件。
|
||||
func judgePinnedPushFileMatch(root *pinnedPullRoot, local localPushFile, remote *remoteFile, quick bool) (string, error) {
|
||||
if quick {
|
||||
if remote.ModifiedTimeValid && local.ModTimeMillis == remote.ModifiedTime {
|
||||
return matchUnchanged, nil
|
||||
}
|
||||
return matchModified, nil
|
||||
}
|
||||
if remote.Hash == "" {
|
||||
return matchUnknown, nil
|
||||
}
|
||||
file, err := root.root.Open(filepath.FromSlash(local.RelPath))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("计算 %s 的 MD5 失败: %w", local.RelPath, err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := pushStatOpenedFile(file)
|
||||
if err != nil || !info.Mode().IsRegular() || (local.scanInfo != nil && !os.SameFile(local.scanInfo, info)) {
|
||||
return "", fmt.Errorf("计算 %s 的 MD5 失败: 本地文件在扫描后被替换", local.RelPath)
|
||||
}
|
||||
hash, err := pushMD5OpenedFile(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("计算 %s 的 MD5 失败: %w", local.RelPath, err)
|
||||
}
|
||||
if err := pushVerifyPinnedSourceRoot(root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if hash == remote.Hash {
|
||||
return matchUnchanged, nil
|
||||
}
|
||||
return matchModified, nil
|
||||
}
|
||||
|
||||
// mirrorPathKey 表示 Drive 镜像契约中的等价路径:逐段 Unicode NFC + 大小写折叠。
|
||||
// 该规则独立于运行命令的本地文件系统,避免 macOS/Windows 与 Drive 间把 a/A 或
|
||||
// NFC/NFD 当成“双向新增”,也避免把等价目录前缀错误拼接成另一棵树。
|
||||
func mirrorPathKey(rel string) string {
|
||||
parts := strings.Split(rel, "/")
|
||||
for i := range parts {
|
||||
parts[i] = norm.NFC.String(strings.ToLower(parts[i]))
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func mirrorLocalRelError(rel string) string {
|
||||
for _, segment := range strings.Split(rel, "/") {
|
||||
if !isSafeRemoteSegment(segment) || strings.IndexByte(segment, 0) >= 0 {
|
||||
return fmt.Sprintf("本地路径 %q 含无法安全映射到远端的名称段 %q,已中止镜像", rel, segment)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildMirrorPreflight 在任何远端写之前统一检查本地名称和双端等价路径/类型。
|
||||
// 每个条目同时声明全部祖先目录,因而 local A/x 与 remote a/y 会在祖先键上冲突;
|
||||
// 即便本地为空,remote A/a 也会被完整性门禁识别。调用方必须整体拒绝写入,不能只
|
||||
// 跳过冲突项。
|
||||
func buildMirrorPreflight(localDirs []string, localFiles []localPushFile, remoteFiles map[string]*remoteFile, remoteFolders map[string]string) map[string]string {
|
||||
type mirrorKind string
|
||||
const (
|
||||
mirrorFile mirrorKind = "文件"
|
||||
mirrorDir mirrorKind = "目录"
|
||||
)
|
||||
type entry struct {
|
||||
rel string
|
||||
kind mirrorKind
|
||||
}
|
||||
local := make(map[string]map[entry]struct{})
|
||||
remote := make(map[string]map[entry]struct{})
|
||||
failures := make(map[string]string)
|
||||
addClaims := func(index map[string]map[entry]struct{}, rel string, kind mirrorKind) {
|
||||
parts := strings.Split(rel, "/")
|
||||
for i := range parts {
|
||||
claimKind := mirrorDir
|
||||
if i == len(parts)-1 {
|
||||
claimKind = kind
|
||||
}
|
||||
claimRel := strings.Join(parts[:i+1], "/")
|
||||
key := mirrorPathKey(claimRel)
|
||||
if index[key] == nil {
|
||||
index[key] = make(map[entry]struct{})
|
||||
}
|
||||
index[key][entry{rel: claimRel, kind: claimKind}] = struct{}{}
|
||||
}
|
||||
}
|
||||
addLocal := func(rel string, kind mirrorKind) {
|
||||
if msg := mirrorLocalRelError(rel); msg != "" {
|
||||
failures[rel] = msg
|
||||
}
|
||||
addClaims(local, rel, kind)
|
||||
}
|
||||
for _, rel := range localDirs {
|
||||
addLocal(rel, mirrorDir)
|
||||
}
|
||||
for _, f := range localFiles {
|
||||
addLocal(f.RelPath, mirrorFile)
|
||||
}
|
||||
for rel := range remoteFolders {
|
||||
if rel != "" {
|
||||
addClaims(remote, rel, mirrorDir)
|
||||
}
|
||||
}
|
||||
for rel := range remoteFiles {
|
||||
addClaims(remote, rel, mirrorFile)
|
||||
}
|
||||
keys := make(map[string]struct{}, len(local)+len(remote))
|
||||
for key := range local {
|
||||
keys[key] = struct{}{}
|
||||
}
|
||||
for key := range remote {
|
||||
keys[key] = struct{}{}
|
||||
}
|
||||
for key := range keys {
|
||||
claims := make(map[entry]struct{}, len(local[key])+len(remote[key]))
|
||||
for claim := range local[key] {
|
||||
claims[claim] = struct{}{}
|
||||
}
|
||||
for claim := range remote[key] {
|
||||
claims[claim] = struct{}{}
|
||||
}
|
||||
if len(claims) <= 1 {
|
||||
continue
|
||||
}
|
||||
for claim := range claims {
|
||||
failures[claim.rel] = fmt.Sprintf("%s路径 %q 在镜像规则下存在大小写/Unicode NFC 等价路径拼写或类型歧义,已中止镜像", claim.kind, claim.rel)
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
func mirrorPreflightFailureRels(preflight map[string]string) []string {
|
||||
rels := make([]string, 0, len(preflight))
|
||||
for rel := range preflight {
|
||||
rels = append(rels, rel)
|
||||
}
|
||||
sort.Strings(rels)
|
||||
return rels
|
||||
}
|
||||
|
||||
// walkLocalForPushEntry 是 walkLocalForPush 的单条目处理逻辑。抽成命名函数只为可测:
|
||||
// filepath.Rel / Info() 的失败在真实 WalkDir 下几乎不可复现,单测可直接注入。
|
||||
func walkLocalForPushEntry(root, path string, d fs.DirEntry, err error, dirs *[]string, files *[]localPushFile) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, path)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
if rel == "." {
|
||||
return nil // 根目录本身不处理
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if d.IsDir() {
|
||||
*dirs = append(*dirs, relSlash)
|
||||
return nil
|
||||
}
|
||||
info, ierr := d.Info()
|
||||
if ierr != nil {
|
||||
return ierr
|
||||
}
|
||||
// 只推送常规文件;符号链接、设备文件等忽略。
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
*files = append(*files, localPushFile{
|
||||
RelPath: relSlash,
|
||||
AbsPath: path,
|
||||
ModTimeMillis: info.ModTime().UnixMilli(),
|
||||
Size: info.Size(),
|
||||
scanInfo: info,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushCreateFolder 在 parentID 下创建名为 name 的文件夹,返回新目录的 fileId。
|
||||
func pushCreateFolder(ctx context.Context, spaceID, parentID, name string) (string, error) {
|
||||
args := map[string]any{"name": name}
|
||||
if spaceID != "" {
|
||||
args["spaceId"] = spaceID
|
||||
}
|
||||
if parentID != "" {
|
||||
args["parentId"] = parentID
|
||||
}
|
||||
text, err := callMCPToolReturnText(ctx, "create_folder", args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseNodeID(text), nil
|
||||
}
|
||||
|
||||
// pushUploadFile 走 get_upload_info → OSS PUT → commit_upload 三步,把本地文件
|
||||
// 上传到 parentID 目录下。复用 drive upload 的凭证解析与 HTTP PUT。
|
||||
//
|
||||
// overwriteFileID 非空时走覆盖流程(与 uploadToDrive 一致):get_upload_info 与
|
||||
// commit_upload 两个阶段都传 overwriteFileId、都不传 parentId(服务端据此设置
|
||||
// conflictStrategy=OVERWRITE,原地覆盖而非在同目录新建重名副本)。
|
||||
func pushUploadFile(ctx context.Context, spaceID, parentID, overwriteFileID, fileName, filePath string, fileSize int64) error {
|
||||
return pushUploadWithTransport(ctx, spaceID, parentID, overwriteFileID, fileName, fileSize,
|
||||
func(resourceURL string, ossHeaders map[string]string) error {
|
||||
return httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize)
|
||||
})
|
||||
}
|
||||
|
||||
// pushUploadFilePinned 从固定本地根打开 rel_path,并在发出 get_upload_info 前核对
|
||||
// 文件身份与扫描快照。HTTP PUT 只读取这个已打开句柄,绝不按 AbsPath 二次解析。
|
||||
func pushUploadFilePinned(ctx context.Context, spaceID, parentID, overwriteFileID, fileName string, root *pinnedPullRoot, local localPushFile) error {
|
||||
if err := root.verify(); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := root.root.Open(filepath.FromSlash(local.RelPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开本地上传文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := pushStatOpenedFile(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取本地上传文件身份失败: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("本地上传目标 %q 已不再是常规文件", local.RelPath)
|
||||
}
|
||||
if local.scanInfo != nil && !os.SameFile(local.scanInfo, info) {
|
||||
return fmt.Errorf("本地上传目标 %q 在扫描后被替换,已中止上传", local.RelPath)
|
||||
}
|
||||
if info.Size() != local.Size || info.ModTime().UnixMilli() != local.ModTimeMillis {
|
||||
return fmt.Errorf("本地上传目标 %q 在扫描后发生变化,已中止上传", local.RelPath)
|
||||
}
|
||||
if err := pushVerifyPinnedSourceRoot(root); err != nil {
|
||||
return err
|
||||
}
|
||||
return pushUploadWithTransport(ctx, spaceID, parentID, overwriteFileID, fileName, local.Size,
|
||||
func(resourceURL string, ossHeaders map[string]string) error {
|
||||
if err := pushPutOpenedFile(ctx, resourceURL, ossHeaders, file, local.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
// OSS 传输期间若源文件被原地改写(编辑器覆盖、截断重写、mmap),
|
||||
// PUT 里发送的可能是新旧内容的混合体,root.verify() 又不检查文件本身。
|
||||
// commit 前对已打开句柄再取一次身份,与 PUT 前的 inode/size/mtime 逐项比对;
|
||||
// 任一变化都拒绝 commit,避免 overwrite/local-wins 场景把混合内容覆盖到远端。
|
||||
after, err := pushStatOpenedFile(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("上传后读取本地上传文件身份失败: %w", err)
|
||||
}
|
||||
if !os.SameFile(info, after) ||
|
||||
after.Size() != info.Size() ||
|
||||
after.ModTime().UnixMilli() != info.ModTime().UnixMilli() {
|
||||
return fmt.Errorf("本地上传目标 %q 在传输期间被修改,已中止提交", local.RelPath)
|
||||
}
|
||||
// OSS 传输期间若命令行根已被替换,不提交这个上传会话;已发送的字节仍来自
|
||||
// 固定根内的原文件句柄,不可能读取替代路径或根外内容。
|
||||
return root.verify()
|
||||
})
|
||||
}
|
||||
|
||||
func pushUploadWithTransport(ctx context.Context, spaceID, parentID, overwriteFileID, fileName string, fileSize int64, put func(string, map[string]string) error) error {
|
||||
step1 := map[string]any{"fileName": fileName, "fileSize": float64(fileSize)}
|
||||
if spaceID != "" {
|
||||
step1["spaceId"] = spaceID
|
||||
}
|
||||
if overwriteFileID != "" {
|
||||
step1["overwriteFileId"] = overwriteFileID
|
||||
} else if parentID != "" {
|
||||
step1["parentId"] = parentID
|
||||
}
|
||||
text, err := callMCPToolReturnText(ctx, "get_upload_info", step1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resourceURL, uploadID, ossHeaders, err := parseDriveUploadInfo(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := put(resourceURL, ossHeaders); err != nil {
|
||||
return err
|
||||
}
|
||||
commit := map[string]any{"fileName": fileName, "fileSize": float64(fileSize), "uploadId": uploadID}
|
||||
if spaceID != "" {
|
||||
commit["spaceId"] = spaceID
|
||||
}
|
||||
if overwriteFileID != "" {
|
||||
commit["overwriteFileId"] = overwriteFileID
|
||||
} else if parentID != "" {
|
||||
commit["parentId"] = parentID
|
||||
}
|
||||
commitText, err := callMCPToolReturnText(ctx, "commit_upload", commit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(commitText) == "" {
|
||||
return fmt.Errorf("commit_upload returned no business result; remote effect is unknown")
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(commitText), &result); err != nil {
|
||||
return fmt.Errorf("parse commit_upload response: %w", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return fmt.Errorf("commit_upload returned an empty JSON object; remote effect is unknown")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[string]string, file *os.File, fileSize int64) error {
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek upload file: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create upload request: %w", err)
|
||||
}
|
||||
req.ContentLength = fileSize
|
||||
req.Header.Del("Content-Type")
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file upload failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("OSS upload failed: %w", &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseNodeID 从 create_folder / commit 等返回里抽出节点 fileId(带 fallback)。
|
||||
func parseNodeID(text string) string {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return ""
|
||||
}
|
||||
if r, ok := data["result"].(map[string]any); ok {
|
||||
data = r
|
||||
}
|
||||
for _, k := range []string{"fileId", "dentryUuid", "dentryId", "id", "nodeId"} {
|
||||
if s, ok := data[k].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// splitRel 把 rel_path 拆成父路径与末段名:"a/b/c" → ("a/b","c"),"c" → ("","c")。
|
||||
func splitRel(rel string) (string, string) {
|
||||
if i := strings.LastIndex(rel, "/"); i >= 0 {
|
||||
return rel[:i], rel[i+1:]
|
||||
}
|
||||
return "", rel
|
||||
}
|
||||
|
||||
// checkFileNotDingTalkDoc 通过 get_file_info 检查文件类型,
|
||||
// 若为钉钉在线文档 (adoc/axls/amind/adraw) 则返回错误,提示使用对应服务命令。
|
||||
// 探测失败(如无效 fileId)不阻断流程,让后续 MCP 工具自行报错。
|
||||
@@ -0,0 +1,449 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
var errTestUpload = errors.New("simulated upload failure")
|
||||
|
||||
// pushOKCaller 是 push 的“一切正常”mock:list_files 按 parentId 返回远端现状,
|
||||
// create_folder 返回新 fileId,get_upload_info 返回可解析凭证,ln 提交成功。
|
||||
func pushOKCaller(listing map[string]string) *driveScriptCaller {
|
||||
return &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) {
|
||||
switch tool {
|
||||
case "list_files":
|
||||
parent, _ := args["parentId"].(string)
|
||||
if body, ok := listing[parent]; ok {
|
||||
return body, nil
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
case "create_folder":
|
||||
return `{"result":{"fileId":"NEWDIR"},"success":true}`, nil
|
||||
case "get_upload_info":
|
||||
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
||||
}
|
||||
return `{"result":{"fileId":"NEWFILE"},"success":true}`, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func withNoopPut(t *testing.T) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil })
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// runDrivePush — 端到端
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 本地子目录在远端缺失 → 按需 create_folder 并留 folder_created 痕迹,再上传其中文件。
|
||||
func TestCrossPlatformCoverageDrivePush_createsMissingRemoteFolders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d")
|
||||
withNoopPut(t)
|
||||
|
||||
caller := pushOKCaller(map[string]string{"ROOT": `{"result":{"items":[],"nextToken":""}}`})
|
||||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cf := caller.callsFor("create_folder")
|
||||
if len(cf) != 1 || cf[0].args["name"] != "sub" {
|
||||
t.Fatalf("expected create_folder(sub), got %v", cf)
|
||||
}
|
||||
if cf[0].args["parentId"] != "ROOT" {
|
||||
t.Errorf("create_folder parentId = %v, want ROOT", cf[0].args["parentId"])
|
||||
}
|
||||
// 新建目录的 fileId 必须成为其中文件的 parentId。
|
||||
up := caller.callsFor("get_upload_info")
|
||||
if len(up) != 1 || up[0].args["parentId"] != "NEWDIR" {
|
||||
t.Errorf("upload parentId = %v, want NEWDIR", up[0].args["parentId"])
|
||||
}
|
||||
}
|
||||
|
||||
// 远端已存在同名目录 → 复用其 fileId,不重建、不出现在 items[]。
|
||||
func TestCrossPlatformCoverageDrivePush_reusesExistingRemoteFolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d")
|
||||
withNoopPut(t)
|
||||
|
||||
caller := pushOKCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"sub","type":"folder","fileId":"EXISTING"}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := caller.callsFor("create_folder"); len(got) != 0 {
|
||||
t.Errorf("existing remote folder must not be recreated, got %v", got)
|
||||
}
|
||||
if up := caller.callsFor("get_upload_info"); up[0].args["parentId"] != "EXISTING" {
|
||||
t.Errorf("upload parentId = %v, want EXISTING", up[0].args["parentId"])
|
||||
}
|
||||
}
|
||||
|
||||
// create_folder 失败 / 未返回 fileId → 该目录记 failed,其中文件因父目录缺失也 failed。
|
||||
func TestCrossPlatformCoverageDrivePush_folderCreationFailureCascades(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
reply func(string, map[string]any, int) (string, error)
|
||||
}{
|
||||
{"tool error", func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool == "create_folder" {
|
||||
return "", errors.New("create_folder boom")
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}},
|
||||
{"no fileId", func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool == "create_folder" {
|
||||
return `{"result":{},"success":true}`, nil
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d")
|
||||
withNoopPut(t)
|
||||
|
||||
caller := &driveScriptCaller{reply: tc.reply}
|
||||
err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT")
|
||||
var pf *drivePushFailure
|
||||
if !errors.As(err, &pf) {
|
||||
t.Fatalf("expected drivePushFailure, got %T %v", err, err)
|
||||
}
|
||||
// 目录 + 其中文件各记一次失败。
|
||||
if pf.failed != 2 {
|
||||
t.Errorf("failed = %d, want 2 (folder + orphaned file)", pf.failed)
|
||||
}
|
||||
if got := caller.callsFor("get_upload_info"); len(got) != 0 {
|
||||
t.Errorf("no upload may happen without a parent folder, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --if-exists skip(默认):远端已存在 → 跳过,不上传。
|
||||
func TestCrossPlatformCoverageDrivePush_ifExistsSkipIsDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
|
||||
withNoopPut(t)
|
||||
|
||||
caller := pushOKCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := caller.callsFor("get_upload_info"); len(got) != 0 {
|
||||
t.Errorf("default skip must not upload, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --if-exists smart:远端时间已 ≥ 本地 → 跳过;远端更旧 → 覆盖上传。
|
||||
func TestCrossPlatformCoverageDrivePush_ifExistsSmart(t *testing.T) {
|
||||
t.Run("remote newer skips", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "a.txt")
|
||||
mustWrite(t, p, "local")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withNoopPut(t)
|
||||
|
||||
caller := pushOKCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":` +
|
||||
strconv.FormatInt(local+60000, 10) + `}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := caller.callsFor("get_upload_info"); len(got) != 0 {
|
||||
t.Errorf("smart must skip when remote is newer, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote older overwrites in place", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "a.txt")
|
||||
mustWrite(t, p, "local")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withNoopPut(t)
|
||||
|
||||
caller := pushOKCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":` +
|
||||
strconv.FormatInt(local-60000, 10) + `}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
up := caller.callsFor("get_upload_info")
|
||||
if len(up) != 1 || up[0].args["overwriteFileId"] != "A" {
|
||||
t.Fatalf("smart overwrite must pass overwriteFileId=A, got %v", up)
|
||||
}
|
||||
if _, has := up[0].args["parentId"]; has {
|
||||
t.Error("overwrite upload must not carry parentId")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote time invalid overwrites", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
|
||||
withNoopPut(t)
|
||||
|
||||
// 远端无 modifyTime → 时间不可信,smart 不敢跳过,走覆盖。
|
||||
caller := pushOKCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if up := caller.callsFor("get_upload_info"); len(up) != 1 {
|
||||
t.Fatalf("expected overwrite upload, got %v", up)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 上传失败 → 记 failed 并以非零退出码退出,结构化结果仍打印。
|
||||
func TestCrossPlatformCoverageDrivePush_uploadFailureExitsNonZero(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return errTestUpload })
|
||||
|
||||
caller := pushOKCaller(nil)
|
||||
err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT")
|
||||
var pf *drivePushFailure
|
||||
if !errors.As(err, &pf) || pf.failed != 1 {
|
||||
t.Fatalf("expected 1 push failure, got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// --if-exists 非法值直接拒绝。
|
||||
func TestCrossPlatformCoverageDrivePush_invalidIfExistsRejected(t *testing.T) {
|
||||
err := runDriveCmd(t, pushOKCaller(nil), "push", "--local-folder", t.TempDir(), "--remote-folder", "ROOT", "--if-exists", "bogus")
|
||||
if err == nil || !strings.Contains(err.Error(), "--if-exists") {
|
||||
t.Fatalf("expected --if-exists rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// space-id 必须透传到 create_folder / get_upload_info / ln 三处。
|
||||
func TestCrossPlatformCoverageDrivePush_passesSpaceIDEverywhere(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "sub", "a.txt"), "x")
|
||||
withNoopPut(t)
|
||||
|
||||
caller := pushOKCaller(nil)
|
||||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--space-id", "SP7"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, tool := range []string{"list_files", "create_folder", "get_upload_info"} {
|
||||
calls := caller.callsFor(tool)
|
||||
if len(calls) == 0 {
|
||||
t.Fatalf("%s was never called", tool)
|
||||
}
|
||||
if calls[0].args["spaceId"] != "SP7" {
|
||||
t.Errorf("%s spaceId = %v, want SP7", tool, calls[0].args["spaceId"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 远端列举失败 → push 直接报错,不做任何上传。
|
||||
func TestCrossPlatformCoverageDrivePush_remoteListFailurePropagates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "x")
|
||||
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }}
|
||||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected remote listing failure to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// 本地根目录不存在 → 扫描失败。
|
||||
func TestCrossPlatformCoverageDrivePush_missingLocalRootFails(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "absent")
|
||||
err := runDriveCmd(t, pushOKCaller(nil), "push", "--local-folder", missing, "--remote-folder", "ROOT")
|
||||
if err == nil || !strings.Contains(err.Error(), "扫描本地目录失败") {
|
||||
t.Fatalf("expected local scan failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// walkRemoteForPush — 分页、递归、深度上限、非镜像类型跳过
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageWalkRemoteForPush_paginationRecursionAndSkips(t *testing.T) {
|
||||
// 递归会插在 ROOT 的两页之间,所以按 (parentId, nextToken) 判定而非调用序号。
|
||||
caller := &driveScriptCaller{reply: func(_ string, args map[string]any, _ int) (string, error) {
|
||||
parent, _ := args["parentId"].(string)
|
||||
token, _ := args["nextToken"].(string)
|
||||
if parent == "SUB" {
|
||||
return `{"result":{"items":[{"name":"deep.txt","type":"file","fileId":"D"}],"nextToken":""}}`, nil
|
||||
}
|
||||
if parent == "ROOT" && token == "" {
|
||||
return `{"result":{"items":[{"name":"sub","type":"folder","fileId":"SUB"}],"nextToken":"P2"}}`, nil
|
||||
}
|
||||
if parent == "ROOT" && token == "P2" {
|
||||
return `{"result":{"items":[` +
|
||||
`{"name":"online.adoc","type":"document","fileId":"DOC"},` +
|
||||
`{"name":"ok.txt","type":"file","fileId":"OK"}` +
|
||||
`],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}}
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
files, folders, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := folders["sub"]; !ok {
|
||||
t.Error("sub folder must be indexed")
|
||||
}
|
||||
if _, ok := files["sub/deep.txt"]; !ok {
|
||||
t.Error("nested file must be indexed with joined rel_path")
|
||||
}
|
||||
if _, ok := files["ok.txt"]; !ok {
|
||||
t.Error("second page file must be indexed")
|
||||
}
|
||||
if _, ok := files["online.adoc"]; ok {
|
||||
t.Error("non-file document must remain outside the folder mirror index")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWalkRemoteForPush_depthLimitAborts(t *testing.T) {
|
||||
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
||||
return `{"result":{"items":[{"name":"loop","type":"folder","fileId":"LOOP"}],"nextToken":""}}`, nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
if _, _, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT"); err == nil ||
|
||||
!strings.Contains(err.Error(), "循环引用") {
|
||||
t.Fatalf("expected depth-limit abort, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// walkLocalForPush — 非常规文件与错误分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageWalkLocalForPush_skipsIrregularAndMissingRoot(t *testing.T) {
|
||||
if _, _, err := walkLocalForPush(filepath.Join(t.TempDir(), "absent")); err == nil {
|
||||
t.Fatal("expected error for missing root")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "real.txt"), "x")
|
||||
if err := os.Symlink(filepath.Join(dir, "absent"), filepath.Join(dir, "dangling")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
_, files, err := walkLocalForPush(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, f := range files {
|
||||
if f.RelPath == "dangling" {
|
||||
t.Error("dangling symlink must not be pushed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// pushUploadFile — 凭证解析与提交失败分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoveragePushUploadFile_failureBranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "a.txt")
|
||||
mustWrite(t, p, "payload")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
reply func(string, map[string]any, int) (string, error)
|
||||
put func(context.Context, string, map[string]string, string, int64) error
|
||||
}{
|
||||
{"get_upload_info tool error",
|
||||
func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool == "get_upload_info" {
|
||||
return "", errTestUpload
|
||||
}
|
||||
return `{"result":{},"success":true}`, nil
|
||||
}, nil},
|
||||
{"unparsable upload credential",
|
||||
func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool == "get_upload_info" {
|
||||
return `{"result":{}}`, nil // 无 resourceUrls
|
||||
}
|
||||
return `{"result":{},"success":true}`, nil
|
||||
}, nil},
|
||||
{"oss put fails",
|
||||
func(string, map[string]any, int) (string, error) {
|
||||
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
||||
},
|
||||
func(context.Context, string, map[string]string, string, int64) error { return errTestUpload }},
|
||||
{"commit fails",
|
||||
func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool == "get_upload_info" {
|
||||
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
||||
}
|
||||
return "", errTestUpload
|
||||
}, nil},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &deps, &Deps{Caller: &driveScriptCaller{reply: tc.reply}, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
put := tc.put
|
||||
if put == nil {
|
||||
put = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
}
|
||||
testseam.Swap(t, &httpPutFile, put)
|
||||
|
||||
if err := pushUploadFile(context.Background(), "SP", "PARENT", "", "a.txt", p, 7); err == nil {
|
||||
t.Fatal("expected upload failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pushCreateFolder:MCP 失败上抛,成功时从返回体提取 fileId。
|
||||
func TestCrossPlatformCoveragePushCreateFolder_errorAndSuccess(t *testing.T) {
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
||||
return "", errTestUpload
|
||||
}}, Out: &Formatter{w: io.Discard}})
|
||||
if _, err := pushCreateFolder(context.Background(), "", "P", "n"); err == nil {
|
||||
t.Fatal("expected create_folder error to propagate")
|
||||
}
|
||||
|
||||
ok := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
||||
return `{"result":{"fileId":"FID42"},"success":true}`, nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: ok, Out: &Formatter{w: io.Discard}})
|
||||
got, err := pushCreateFolder(context.Background(), "SP", "P", "n")
|
||||
if err != nil || got != "FID42" {
|
||||
t.Fatalf("pushCreateFolder = (%q, %v), want (FID42, nil)", got, err)
|
||||
}
|
||||
if ok.calls[0].args["spaceId"] != "SP" || ok.calls[0].args["parentId"] != "P" {
|
||||
t.Errorf("create_folder args = %v", ok.calls[0].args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
var errDriveFinalCoverage = errors.New("drive final coverage failure")
|
||||
|
||||
func openDriveFinalCoverageRoot(t *testing.T) (string, *pinnedPullRoot, localPushFile) {
|
||||
t.Helper()
|
||||
rootPath := t.TempDir()
|
||||
path := filepath.Join(rootPath, "a.txt")
|
||||
mustWrite(t, path, "payload")
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := openExistingPinnedPullRoot(rootPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(root.close)
|
||||
return rootPath, root, localPushFile{
|
||||
RelPath: "a.txt", AbsPath: path, Size: info.Size(),
|
||||
ModTimeMillis: info.ModTime().UnixMilli(), scanInfo: info,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushFinalMD5AndPinnedMatchEdges(t *testing.T) {
|
||||
t.Run("default md5 success and read failure", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "payload")
|
||||
mustWrite(t, path, "payload")
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pushMD5OpenedFile(file)
|
||||
_ = file.Close()
|
||||
wantSum := md5.Sum([]byte("payload"))
|
||||
want := base64.StdEncoding.EncodeToString(wantSum[:])
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("md5=(%q,%v), want %q", got, err, want)
|
||||
}
|
||||
if _, err := pushMD5OpenedFile(file); err == nil {
|
||||
t.Fatal("closed file must fail MD5 reading")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("open failure", func(t *testing.T) {
|
||||
_, root, local := openDriveFinalCoverageRoot(t)
|
||||
local.RelPath = "missing.txt"
|
||||
if _, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: "x"}, false); err == nil {
|
||||
t.Fatal("missing file must fail exact matching")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stat and identity failures", func(t *testing.T) {
|
||||
_, root, local := openDriveFinalCoverageRoot(t)
|
||||
testseam.Swap(t, &pushStatOpenedFile, func(*os.File) (os.FileInfo, error) {
|
||||
return nil, errDriveFinalCoverage
|
||||
})
|
||||
if _, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: "x"}, false); err == nil {
|
||||
t.Fatal("opened file stat failure must abort matching")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directory and replaced scan identity", func(t *testing.T) {
|
||||
rootPath, root, local := openDriveFinalCoverageRoot(t)
|
||||
if _, err := judgePinnedPushFileMatch(root, localPushFile{RelPath: "."}, &remoteFile{Hash: "x"}, false); err == nil {
|
||||
t.Fatal("directory must not be hashed as a regular file")
|
||||
}
|
||||
other := filepath.Join(rootPath, "other.txt")
|
||||
mustWrite(t, other, "other")
|
||||
otherInfo, _ := os.Lstat(other)
|
||||
local.scanInfo = otherInfo
|
||||
if _, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: "x"}, false); err == nil {
|
||||
t.Fatal("scan identity mismatch must abort matching")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hash failure and root verification", func(t *testing.T) {
|
||||
_, root, local := openDriveFinalCoverageRoot(t)
|
||||
testseam.Swap(t, &pushMD5OpenedFile, func(*os.File) (string, error) {
|
||||
return "", errDriveFinalCoverage
|
||||
})
|
||||
if _, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: "x"}, false); err == nil {
|
||||
t.Fatal("hash read failure must abort matching")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("root verification and both verdicts", func(t *testing.T) {
|
||||
_, root, local := openDriveFinalCoverageRoot(t)
|
||||
sum := md5.Sum([]byte("payload"))
|
||||
hash := base64.StdEncoding.EncodeToString(sum[:])
|
||||
testseam.Swap(t, &pushVerifyPinnedSourceRoot, func(*pinnedPullRoot) error {
|
||||
return errDriveFinalCoverage
|
||||
})
|
||||
if _, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: hash}, false); !errors.Is(err, errDriveFinalCoverage) {
|
||||
t.Fatalf("root verification error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unchanged and modified", func(t *testing.T) {
|
||||
_, root, local := openDriveFinalCoverageRoot(t)
|
||||
sum := md5.Sum([]byte("payload"))
|
||||
hash := base64.StdEncoding.EncodeToString(sum[:])
|
||||
if got, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: hash}, false); err != nil || got != matchUnchanged {
|
||||
t.Fatalf("equal hash=(%q,%v)", got, err)
|
||||
}
|
||||
if got, err := judgePinnedPushFileMatch(root, local, &remoteFile{Hash: "different"}, false); err != nil || got != matchModified {
|
||||
t.Fatalf("different hash=(%q,%v)", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushFinalPinnedUploadGuards(t *testing.T) {
|
||||
t.Run("initial root replacement", func(t *testing.T) {
|
||||
rootPath, root, local := openDriveFinalCoverageRoot(t)
|
||||
moved := filepath.Join(filepath.Dir(rootPath), "moved-root")
|
||||
replacePinnedMirrorRoot(t, rootPath, moved)
|
||||
if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local); err == nil {
|
||||
t.Fatal("replaced root must abort before opening upload")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("open and stat failures", func(t *testing.T) {
|
||||
_, root, local := openDriveFinalCoverageRoot(t)
|
||||
_ = root.root.Close()
|
||||
if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local); err == nil {
|
||||
t.Fatal("closed pinned root must fail file open")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("opened file stat failure", func(t *testing.T) {
|
||||
_, root, local := openDriveFinalCoverageRoot(t)
|
||||
testseam.Swap(t, &pushStatOpenedFile, func(*os.File) (os.FileInfo, error) {
|
||||
return nil, errDriveFinalCoverage
|
||||
})
|
||||
if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local); err == nil {
|
||||
t.Fatal("stat failure must abort upload")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non regular replaced and changed", func(t *testing.T) {
|
||||
rootPath, root, local := openDriveFinalCoverageRoot(t)
|
||||
if err := pushUploadFilePinned(context.Background(), "", "P", "", "root", root, localPushFile{RelPath: "."}); err == nil {
|
||||
t.Fatal("directory upload must fail")
|
||||
}
|
||||
other := filepath.Join(rootPath, "other")
|
||||
mustWrite(t, other, "other")
|
||||
otherInfo, _ := os.Lstat(other)
|
||||
replaced := local
|
||||
replaced.scanInfo = otherInfo
|
||||
if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, replaced); err == nil {
|
||||
t.Fatal("replaced scan identity must fail")
|
||||
}
|
||||
changed := local
|
||||
changed.Size++
|
||||
if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, changed); err == nil {
|
||||
t.Fatal("changed size must fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("second root verification", func(t *testing.T) {
|
||||
_, root, local := openDriveFinalCoverageRoot(t)
|
||||
testseam.Swap(t, &pushVerifyPinnedSourceRoot, func(*pinnedPullRoot) error {
|
||||
return errDriveFinalCoverage
|
||||
})
|
||||
if err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local); !errors.Is(err, errDriveFinalCoverage) {
|
||||
t.Fatalf("second verification error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// PUT 期间源文件被原地改写(编辑器覆盖、截断重写、mmap)时,PUT 已把混合内容
|
||||
// 送到 OSS;如果 commit 只看根身份而不复核文件本身,overwrite/local-wins 会把
|
||||
// 半新半旧的字节流覆盖到远端。commit 前必须再取一次源身份,size/mtime/inode
|
||||
// 任一变化都要拒绝提交。
|
||||
for _, mutation := range []struct {
|
||||
name string
|
||||
mutate func(t *testing.T, dir string)
|
||||
}{
|
||||
{"size grows during PUT", func(t *testing.T, dir string) {
|
||||
path := filepath.Join(dir, "a.txt")
|
||||
if err := os.WriteFile(path, []byte("payload-mutated-mid-transfer"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{"mtime advances during PUT", func(t *testing.T, dir string) {
|
||||
path := filepath.Join(dir, "a.txt")
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(path, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run("mid transfer source modification: "+mutation.name, func(t *testing.T) {
|
||||
rootPath, root, local := openDriveFinalCoverageRoot(t)
|
||||
caller := pushOKCaller(nil)
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
putCalls := 0
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
putCalls++
|
||||
mutation.mutate(t, rootPath)
|
||||
return nil
|
||||
})
|
||||
|
||||
err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local)
|
||||
if err == nil || !strings.Contains(err.Error(), "在传输期间被修改") {
|
||||
t.Fatalf("PUT-time mutation must abort commit, got err=%v", err)
|
||||
}
|
||||
if putCalls != 1 {
|
||||
t.Fatalf("PUT must run exactly once, got %d", putCalls)
|
||||
}
|
||||
if commits := caller.callsFor("commit_upload"); len(commits) != 0 {
|
||||
t.Fatalf("commit_upload must not run after mid-transfer mutation, got %v", commits)
|
||||
}
|
||||
})
|
||||
// PUT 之后再取源身份可能因文件被移动/删除失败:那也必须拒绝 commit,防止
|
||||
// 一个不能被验证的传输被提交到远端。
|
||||
t.Run("mid transfer stat failure aborts commit", func(t *testing.T) {
|
||||
_, root, local := openDriveFinalCoverageRoot(t)
|
||||
caller := pushOKCaller(nil)
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
calls := 0
|
||||
testseam.Swap(t, &pushStatOpenedFile, func(file *os.File) (os.FileInfo, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return file.Stat()
|
||||
}
|
||||
return nil, errDriveFinalCoverage
|
||||
})
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
err := pushUploadFilePinned(context.Background(), "", "P", "", "a.txt", root, local)
|
||||
if err == nil || !strings.Contains(err.Error(), "上传后读取本地上传文件身份失败") {
|
||||
t.Fatalf("post-PUT stat failure must abort commit, got %v", err)
|
||||
}
|
||||
if commits := caller.callsFor("commit_upload"); len(commits) != 0 {
|
||||
t.Fatalf("commit_upload must not run after post-PUT stat failure, got %v", commits)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("command scan errors", func(t *testing.T) {
|
||||
for _, command := range []string{"push", "sync"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
testseam.Swap(t, &runDriveMirrorWalkLocalForPushPinned, func(*pinnedPullRoot) ([]string, []localPushFile, error) {
|
||||
return nil, nil, errDriveFinalCoverage
|
||||
})
|
||||
err := runDriveCmd(t, pushOKCaller(nil), command, "--local-folder", t.TempDir(), "--remote-folder", "ROOT")
|
||||
if !errors.Is(err, errDriveFinalCoverage) {
|
||||
t.Fatalf("scan error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("walk initial and callback errors", func(t *testing.T) {
|
||||
rootPath, root, _ := openDriveFinalCoverageRoot(t)
|
||||
moved := filepath.Join(filepath.Dir(rootPath), "moved-walk-root")
|
||||
replacePinnedMirrorRoot(t, rootPath, moved)
|
||||
if _, _, err := walkLocalForPushPinned(root); err == nil {
|
||||
t.Fatal("initial root replacement must fail walk")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("walk entry info error", func(t *testing.T) {
|
||||
_, root, _ := openDriveFinalCoverageRoot(t)
|
||||
testseam.Swap(t, &walkPinnedLocalFS, func(_ fs.FS, _ string, fn fs.WalkDirFunc) error {
|
||||
return fn("bad", errDirEntry{}, nil)
|
||||
})
|
||||
if _, _, err := walkLocalForPushPinned(root); !errors.Is(err, errTestInfo) {
|
||||
t.Fatalf("walk info error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// fs.WalkDir 允许把中途的 lstat 失败通过第三个参数传给回调而不是 DirEntry.Info;
|
||||
// 该短路分支必须直接原样上抛,不能吞掉底层错误。macOS 上并非每次遍历都会经过它,
|
||||
// 因此需要一个显式测试固定这条路径。
|
||||
t.Run("walk callback receives error", func(t *testing.T) {
|
||||
_, root, _ := openDriveFinalCoverageRoot(t)
|
||||
testseam.Swap(t, &walkPinnedLocalFS, func(_ fs.FS, _ string, fn fs.WalkDirFunc) error {
|
||||
return fn("bad", nil, errTestInfo)
|
||||
})
|
||||
if _, _, err := walkLocalForPushPinned(root); !errors.Is(err, errTestInfo) {
|
||||
t.Fatalf("walk callback error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("walk final root verification", func(t *testing.T) {
|
||||
rootPath, root, _ := openDriveFinalCoverageRoot(t)
|
||||
moved := filepath.Join(filepath.Dir(rootPath), "moved-after-walk")
|
||||
testseam.Swap(t, &walkPinnedLocalFS, func(_ fs.FS, _ string, _ fs.WalkDirFunc) error {
|
||||
replacePinnedMirrorRoot(t, rootPath, moved)
|
||||
return nil
|
||||
})
|
||||
if _, _, err := walkLocalForPushPinned(root); err == nil {
|
||||
t.Fatal("post-walk root replacement must fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("regular entry", func(t *testing.T) {
|
||||
var dirs []string
|
||||
var files []localPushFile
|
||||
if err := walkLocalForPushEntry("/root", "/root/a", regularDirEntry{}, nil, &dirs, &files); err != nil || len(files) != 1 {
|
||||
t.Fatalf("files=%v err=%v", files, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("root replacement between folder writes", func(t *testing.T) {
|
||||
for _, command := range []string{"push", "sync"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
rootPath := filepath.Join(parent, "root")
|
||||
if err := os.MkdirAll(filepath.Join(rootPath, "a"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(rootPath, "b"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := filepath.Join(parent, "moved")
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
|
||||
switch tool {
|
||||
case "list_files":
|
||||
return `{"result":{"items":[]}}`, nil
|
||||
case "create_folder":
|
||||
if nth == 0 {
|
||||
replacePinnedMirrorRoot(t, rootPath, moved)
|
||||
}
|
||||
return `{"result":{"fileId":"DIR"}}`, nil
|
||||
default:
|
||||
return `{"result":{"fileId":"FILE"}}`, nil
|
||||
}
|
||||
}}
|
||||
args := []string{command, "--local-folder", rootPath, "--remote-folder", "ROOT"}
|
||||
if command == "sync" {
|
||||
args = append(args, "--quick")
|
||||
}
|
||||
if err := runDriveCmd(t, caller, args...); err == nil || !strings.Contains(err.Error(), "本地根目录") {
|
||||
t.Fatalf("root replacement error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushFinalDefaultOpenedPUTAndCommit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("X-Test") != "yes" {
|
||||
t.Errorf("header=%q", request.Header.Get("X-Test"))
|
||||
}
|
||||
if request.URL.Path == "/status" {
|
||||
http.Error(w, "denied", http.StatusTeapot)
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, request.Body)
|
||||
}))
|
||||
defer server.Close()
|
||||
path := filepath.Join(t.TempDir(), "payload")
|
||||
mustWrite(t, path, "payload")
|
||||
put := func(ctx context.Context, url string, headers map[string]string) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
return defaultPushPutOpenedFile(ctx, url, headers, file, 7)
|
||||
}
|
||||
if err := put(context.Background(), ":", nil); err == nil {
|
||||
t.Fatal("invalid PUT URL must fail")
|
||||
}
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := put(cancelled, server.URL, nil); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled PUT error=%v", err)
|
||||
}
|
||||
if err := put(context.Background(), server.URL+"/status", map[string]string{"X-Test": "yes"}); err == nil {
|
||||
t.Fatal("non-200 PUT must fail")
|
||||
}
|
||||
if err := put(context.Background(), server.URL+"/ok", map[string]string{"X-Test": "yes"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closed, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = closed.Close()
|
||||
if err := defaultPushPutOpenedFile(context.Background(), server.URL, nil, closed, 7); err == nil {
|
||||
t.Fatal("closed upload file must fail seek")
|
||||
}
|
||||
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool == "get_upload_info" {
|
||||
return `{"result":{"resourceUrls":[{"url":"https://upload.invalid","headers":{}}],"uploadId":"U"}}`, nil
|
||||
}
|
||||
return "{", nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
if err := pushUploadWithTransport(context.Background(), "", "P", "", "a.txt", 7, func(string, map[string]string) error { return nil }); err == nil || !strings.Contains(err.Error(), "parse commit_upload") {
|
||||
t.Fatalf("malformed commit error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncFinalPreflightAndKeepBothErrors(t *testing.T) {
|
||||
t.Run("local target preflight lstat error and directory", func(t *testing.T) {
|
||||
_, root, _ := openDriveFinalCoverageRoot(t)
|
||||
failures := map[string]string{}
|
||||
testseam.Swap(t, &pullRootLstat, func(*os.Root, string) (os.FileInfo, error) {
|
||||
return nil, errDriveFinalCoverage
|
||||
})
|
||||
addPinnedLocalTargetPreflightFailures(root, map[string]*remoteFile{"remote.txt": {}}, failures)
|
||||
if !strings.Contains(failures["remote.txt"], "检查本地目标失败") {
|
||||
t.Fatalf("failures=%v", failures)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local target preflight walks existing directory", func(t *testing.T) {
|
||||
rootPath, root, _ := openDriveFinalCoverageRoot(t)
|
||||
if err := os.Mkdir(filepath.Join(rootPath, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failures := map[string]string{}
|
||||
addPinnedLocalTargetPreflightFailures(root, map[string]*remoteFile{"sub/remote.txt": {}}, failures)
|
||||
if len(failures) != 0 {
|
||||
t.Fatalf("failures=%v", failures)
|
||||
}
|
||||
addPinnedLocalTargetPreflightFailures(root, map[string]*remoteFile{"sub": {}}, failures)
|
||||
if failures["sub"] == "" {
|
||||
t.Fatal("terminal directory must fail file target preflight")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrapper root failures", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "file")
|
||||
mustWrite(t, path, "x")
|
||||
pullResult := &driveSyncResult{}
|
||||
syncPullFile(pullResult, context.Background(), "", &remoteFile{}, path, "a", syncDirectionPull)
|
||||
if pullResult.Summary.Failed != 1 {
|
||||
t.Fatalf("pull result=%#v", pullResult)
|
||||
}
|
||||
keepResult := &driveSyncResult{}
|
||||
syncKeepBoth(keepResult, context.Background(), "", &remoteFile{}, path, "a", map[string]bool{})
|
||||
if keepResult.Summary.Failed != 1 {
|
||||
t.Fatalf("keep result=%#v", keepResult)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing and non-regular local versions", func(t *testing.T) {
|
||||
rootPath := t.TempDir()
|
||||
missing := &driveSyncResult{}
|
||||
syncKeepBoth(missing, context.Background(), "", &remoteFile{FileID: "F"}, rootPath, "missing.txt", map[string]bool{})
|
||||
if missing.Summary.Failed != 1 || !strings.Contains(missing.Items[0].Error, "不存在") {
|
||||
t.Fatalf("missing result=%#v", missing)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(rootPath, "dir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonRegular := &driveSyncResult{}
|
||||
syncKeepBoth(nonRegular, context.Background(), "", &remoteFile{FileID: "F"}, rootPath, "dir", map[string]bool{})
|
||||
if nonRegular.Summary.Failed != 1 || !strings.Contains(nonRegular.Items[0].Error, "不是常规文件") {
|
||||
t.Fatalf("non-regular result=%#v", nonRegular)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("saved identity mismatch", func(t *testing.T) {
|
||||
rootPath := t.TempDir()
|
||||
mustWrite(t, filepath.Join(rootPath, "a.txt"), "local")
|
||||
other := filepath.Join(t.TempDir(), "other")
|
||||
mustWrite(t, other, "other")
|
||||
otherInfo, _ := os.Lstat(other)
|
||||
candidate := filepath.FromSlash(syncKeepBothCandidate("a.txt", "F", 0))
|
||||
testseam.Swap(t, &pullRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == candidate {
|
||||
return otherInfo, nil
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
res := &driveSyncResult{}
|
||||
syncKeepBoth(res, context.Background(), "", &remoteFile{FileID: "F"}, rootPath, "a.txt", map[string]bool{})
|
||||
if res.Summary.Failed != 1 || !strings.Contains(res.Items[0].Error, "身份不一致") {
|
||||
t.Fatalf("result=%#v", res)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil transfer error fallback", func(t *testing.T) {
|
||||
rootPath := t.TempDir()
|
||||
mustWrite(t, filepath.Join(rootPath, "a.txt"), "local")
|
||||
testseam.Swap(t, &syncPullOneFilePinned, func(context.Context, string, *remoteFile, *pinnedPullTarget, string) (string, error) {
|
||||
return pullActionFailed, nil
|
||||
})
|
||||
res := &driveSyncResult{}
|
||||
syncKeepBoth(res, context.Background(), "", &remoteFile{FileID: "F"}, rootPath, "a.txt", map[string]bool{})
|
||||
if res.Summary.Failed != 1 || !strings.Contains(res.Items[0].Error, "未能拉取") {
|
||||
t.Fatalf("result=%#v", res)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsafe candidate and parent verification", func(t *testing.T) {
|
||||
rootPath := t.TempDir()
|
||||
mustWrite(t, filepath.Join(rootPath, "a.txt"), "local")
|
||||
target, err := openPinnedPullTarget(rootPath, "a.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := reserveSyncKeepBothTargetPinned(target, "\x00", "", map[string]bool{}); err == nil {
|
||||
t.Fatal("unsafe candidate must fail")
|
||||
}
|
||||
target.close()
|
||||
|
||||
target, err = openPinnedPullTarget(rootPath, "a.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer target.close()
|
||||
moved := filepath.Join(filepath.Dir(rootPath), "moved-keep-root")
|
||||
replacePinnedMirrorRoot(t, rootPath, moved)
|
||||
if _, _, err := reserveSyncKeepBothTargetPinned(target, "a.txt", "", map[string]bool{}); err == nil {
|
||||
t.Fatal("replaced parent must fail before link")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type driveFinalCallbackReader struct{ callback func() }
|
||||
|
||||
func (reader driveFinalCallbackReader) Read(buffer []byte) (int, error) {
|
||||
reader.callback()
|
||||
return copy(buffer, "s\n"), nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncFinalPostDecisionRootVerification(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
rootPath := filepath.Join(parent, "root")
|
||||
if err := os.Mkdir(rootPath, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(rootPath, "a.txt")
|
||||
mustWrite(t, path, "local")
|
||||
mtime := readFileMTimeMillis(t, path)
|
||||
moved := filepath.Join(parent, "moved")
|
||||
testseam.Swap(t, &syncAskStdin, io.Reader(driveFinalCallbackReader{callback: func() {
|
||||
replacePinnedMirrorRoot(t, rootPath, moved)
|
||||
}}))
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"F","modifyTime":` + differentMillis(mtime) + `}]}}`,
|
||||
})
|
||||
err := runDriveCmd(t, caller, "sync", "--local-folder", rootPath, "--remote-folder", "ROOT", "--quick", "--on-conflict", "ask")
|
||||
if err == nil || !strings.Contains(err.Error(), "本地根目录") {
|
||||
t.Fatalf("post-decision root verification error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushSyncFinalCommandValidationAndPrintErrors(t *testing.T) {
|
||||
for _, command := range []string{"push", "sync"} {
|
||||
t.Run(command+" required", func(t *testing.T) {
|
||||
cmd := findDriveSubcommand(t, command)
|
||||
var err error
|
||||
if command == "push" {
|
||||
err = runDrivePush(cmd, nil)
|
||||
} else {
|
||||
err = runDriveSync(cmd, nil)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "required") {
|
||||
t.Fatalf("required error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(command+" relative", func(t *testing.T) {
|
||||
cmd := findDriveSubcommand(t, command)
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": "relative", "remote-folder": "ROOT"})
|
||||
var err error
|
||||
if command == "push" {
|
||||
err = runDrivePush(cmd, nil)
|
||||
} else {
|
||||
err = runDriveSync(cmd, nil)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "绝对路径") {
|
||||
t.Fatalf("relative error=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(command+" preflight print", func(t *testing.T) {
|
||||
rootPath := t.TempDir()
|
||||
mustWrite(t, filepath.Join(rootPath, "A.txt"), "local")
|
||||
caller := pushOKCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"F"}]}}`,
|
||||
})
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: failingWriter{}}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", command})
|
||||
cmd := findDriveSubcommand(t, command)
|
||||
flags := map[string]string{"local-folder": rootPath, "remote-folder": "ROOT"}
|
||||
if command == "sync" {
|
||||
flags["quick"] = "true"
|
||||
}
|
||||
mustSetFlags(t, cmd, flags)
|
||||
var err error
|
||||
if command == "push" {
|
||||
err = runDrivePush(cmd, nil)
|
||||
} else {
|
||||
err = runDriveSync(cmd, nil)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("PrintJSON error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncFinalPullWrapperSuccess(t *testing.T) {
|
||||
rootPath := t.TempDir()
|
||||
installPinnedPullSuccess(t)
|
||||
res := &driveSyncResult{}
|
||||
syncPullFile(res, context.Background(), "", &remoteFile{FileID: "F"}, rootPath, "a.txt", syncDirectionPull)
|
||||
if res.Summary.Pulled != 1 || res.Summary.Failed != 0 {
|
||||
t.Fatalf("result=%#v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushFinalPureRemainingEdges(t *testing.T) {
|
||||
if got := mirrorLocalRelError("good/../bad"); got == "" {
|
||||
t.Fatal("unsafe local mirror segment must fail")
|
||||
}
|
||||
failures := buildMirrorPreflight(nil, []localPushFile{{RelPath: "bad\x00name"}}, nil, nil)
|
||||
if failures["bad\x00name"] == "" {
|
||||
t.Fatalf("failures=%v", failures)
|
||||
}
|
||||
if got := parseNodeID("{"); got != "" {
|
||||
t.Fatalf("malformed node ID=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushFinalRemoteWalkerFailClosedEdges(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
reply func(map[string]any) string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unsafe entry",
|
||||
reply: func(map[string]any) string {
|
||||
return `{"result":{"items":[{"name":"../escape","type":"file","fileId":"F"}]}}`
|
||||
},
|
||||
want: "无法安全映射",
|
||||
},
|
||||
{
|
||||
name: "duplicate folder",
|
||||
reply: func(args map[string]any) string {
|
||||
if args["parentId"] == "D" {
|
||||
return `{"result":{"items":[]}}`
|
||||
}
|
||||
return `{"result":{"items":[{"name":"dir","type":"folder","fileId":"D"},{"name":"dir","type":"folder","fileId":"D"}]}}`
|
||||
},
|
||||
want: "重复",
|
||||
},
|
||||
{
|
||||
name: "folder without ID",
|
||||
reply: func(map[string]any) string {
|
||||
return `{"result":{"items":[{"name":"dir","type":"folder"}]}}`
|
||||
},
|
||||
want: "缺少目录 ID",
|
||||
},
|
||||
{
|
||||
name: "duplicate file",
|
||||
reply: func(map[string]any) string {
|
||||
return `{"result":{"items":[{"name":"a","type":"file","fileId":"A"},{"name":"a","type":"file","fileId":"A"}]}}`
|
||||
},
|
||||
want: "重复",
|
||||
},
|
||||
{
|
||||
name: "file without ID",
|
||||
reply: func(map[string]any) string {
|
||||
return `{"result":{"items":[{"name":"a","type":"file"}]}}`
|
||||
},
|
||||
want: "缺少文件 ID",
|
||||
},
|
||||
{
|
||||
name: "pagination cycle",
|
||||
reply: func(map[string]any) string {
|
||||
return `{"result":{"items":[],"nextToken":"P"}}`
|
||||
},
|
||||
want: "分页 token 重复",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
t.Fatalf("unexpected tool %s", tool)
|
||||
}
|
||||
return tc.reply(args), nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
_, _, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT")
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error=%v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// splitRel — rel_path 拆父路径 / 末段名
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageSplitRel(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
parent, base string
|
||||
}{
|
||||
{"a/b/c", "a/b", "c"},
|
||||
{"a/b", "a", "b"},
|
||||
{"c", "", "c"},
|
||||
{"", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
p, b := splitRel(c.in)
|
||||
if p != c.parent || b != c.base {
|
||||
t.Errorf("splitRel(%q) = (%q,%q), want (%q,%q)", c.in, p, b, c.parent, c.base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// parseNodeID — create_folder / commit 返回里抽 fileId
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageParseNodeID(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{"result.fileId", `{"result":{"fileId":"F1","name":"x"},"success":true}`, "F1"},
|
||||
{"top-level fileId", `{"fileId":"F2"}`, "F2"},
|
||||
{"dentryUuid fallback", `{"result":{"dentryUuid":"D1"}}`, "D1"},
|
||||
{"dentryId fallback", `{"result":{"dentryId":"D2"}}`, "D2"},
|
||||
{"missing", `{"result":{"name":"x"}}`, ""},
|
||||
{"invalid json", `not json`, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := parseNodeID(c.text); got != c.want {
|
||||
t.Errorf("parseNodeID = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// walkLocalForPush — 本地遍历(含空目录,父目录先于子目录)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageWalkLocalForPush(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "hello") // 5 bytes
|
||||
mustWrite(t, filepath.Join(root, "sub1", "b.txt"), "worl") // 4 bytes, 建出 sub1
|
||||
if err := os.MkdirAll(filepath.Join(root, "sub1", "sub2"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dirs, files, err := walkLocalForPush(root)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// 目录:含空目录,不含根本身,且父目录先于子目录(字典序即可保证)。
|
||||
wantDirs := []string{"empty", "sub1", "sub1/sub2"}
|
||||
if !sort.StringsAreSorted(dirs) {
|
||||
t.Errorf("dirs should be sorted (parent before child): %v", dirs)
|
||||
}
|
||||
if !equalStrings(dirs, wantDirs) {
|
||||
t.Errorf("dirs = %v, want %v", dirs, wantDirs)
|
||||
}
|
||||
|
||||
// 文件:rel_path 用 /,记录 size 与 mtime。
|
||||
byRel := map[string]localPushFile{}
|
||||
for _, f := range files {
|
||||
byRel[f.RelPath] = f
|
||||
}
|
||||
if len(byRel) != 2 {
|
||||
t.Fatalf("expected 2 files, got %d: %v", len(byRel), files)
|
||||
}
|
||||
a, ok := byRel["a.txt"]
|
||||
if !ok || a.Size != 5 {
|
||||
t.Errorf("a.txt size = %d, want 5 (present=%v)", a.Size, ok)
|
||||
}
|
||||
if a.AbsPath == "" || a.ModTimeMillis == 0 {
|
||||
t.Error("file should record AbsPath and mtime")
|
||||
}
|
||||
b, ok := byRel["sub1/b.txt"]
|
||||
if !ok || b.Size != 4 {
|
||||
t.Errorf("sub1/b.txt size = %d, want 4 (present=%v)", b.Size, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// drivePushFailure — push 部分失败错误:exit=1
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushFailure(t *testing.T) {
|
||||
e := &drivePushFailure{failed: 3}
|
||||
if e.ExitCode() != 1 {
|
||||
t.Errorf("ExitCode() = %d, want 1", e.ExitCode())
|
||||
}
|
||||
if e.Error() != e.RawStderr() {
|
||||
t.Error("Error() 与 RawStderr() 应一致")
|
||||
}
|
||||
if !strings.Contains(e.Error(), "3") {
|
||||
t.Errorf("Error() 应包含失败数, got %q", e.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// push 覆盖流程 — get_upload_info / commit_upload 两阶段的 MCP 参数
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// driveMCPCall 记录一次 MCP 工具调用。
|
||||
type driveMCPCall struct {
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
// driveSyncMockCaller 按工具名分发返回,并记录全部调用,供断言 MCP 参数。
|
||||
type driveSyncMockCaller struct {
|
||||
calls []driveMCPCall
|
||||
listJSON string // list_files 返回体
|
||||
commitJSON *string // 非 nil 时覆盖 commit_upload 返回体(包括空响应)
|
||||
dryRun bool // --dry-run:只算差异,不执行任何写操作
|
||||
}
|
||||
|
||||
func (m *driveSyncMockCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
m.calls = append(m.calls, driveMCPCall{toolName: toolName, args: args})
|
||||
var text string
|
||||
switch toolName {
|
||||
case "list_files":
|
||||
text = m.listJSON
|
||||
case "get_upload_info":
|
||||
// 返回可被 parseDriveUploadInfo 解析的凭证。
|
||||
text = `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`
|
||||
case "download_file":
|
||||
// 返回可被 parseDriveDownloadInfo 解析的预签名下载链接。
|
||||
text = `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`
|
||||
case "commit_upload":
|
||||
if m.commitJSON != nil {
|
||||
text = *m.commitJSON
|
||||
} else {
|
||||
text = `{"result":{"fileId":"NEW_FID"},"success":true}`
|
||||
}
|
||||
default:
|
||||
text = `{"result":{"fileId":"NEW_FID"},"success":true}`
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (m *driveSyncMockCaller) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return m.CallTool(ctx, productID, toolName, args)
|
||||
}
|
||||
|
||||
func (m *driveSyncMockCaller) Format() string { return "raw" }
|
||||
func (m *driveSyncMockCaller) DryRun() bool { return m.dryRun }
|
||||
func (m *driveSyncMockCaller) Fields() string { return "" }
|
||||
func (m *driveSyncMockCaller) JQ() string { return "" }
|
||||
|
||||
func (m *driveSyncMockCaller) callsFor(tool string) []driveMCPCall {
|
||||
var out []driveMCPCall
|
||||
for _, c := range m.calls {
|
||||
if c.toolName == tool {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runDrivePushTest 用 mock caller 执行 `drive push`,返回捕获的调用。
|
||||
// 注入已打开文件句柄的 PUT seam 为 no-op,避免真实 OSS 上传。
|
||||
func runDrivePushTest(t *testing.T, caller *driveSyncMockCaller, localDir string, args ...string) error {
|
||||
t.Helper()
|
||||
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil })
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.AddCommand(newDriveCommand())
|
||||
|
||||
// pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。
|
||||
full := append([]string{"push", "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"}, args...)
|
||||
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...))
|
||||
root.SetArgs(append([]string{"drive"}, full...))
|
||||
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func runDriveMirrorUploadCommandCapture(t *testing.T, command string, caller *driveSyncMockCaller, localDir string) (error, string, int) {
|
||||
t.Helper()
|
||||
putCalls := 0
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
putCalls++
|
||||
return nil
|
||||
})
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.AddCommand(newDriveCommand())
|
||||
full := []string{command, "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"}
|
||||
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...))
|
||||
root.SetArgs(append([]string{"drive"}, full...))
|
||||
return root.Execute(), out.String(), putCalls
|
||||
}
|
||||
|
||||
func assertMirrorUploadFailedJSON(t *testing.T, output string) {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Summary struct {
|
||||
Failed int `json:"failed"`
|
||||
} `json:"summary"`
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
Error string `json:"error"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(output), &payload); err != nil {
|
||||
t.Fatalf("output is not structured JSON: %q: %v", output, err)
|
||||
}
|
||||
if payload.Summary.Failed != 1 || len(payload.Items) != 1 || payload.Items[0].Action != pushActionFailed || payload.Items[0].Error == "" {
|
||||
t.Fatalf("structured failure=%#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖分支:get_upload_info 与 commit_upload 两阶段都必须传 overwriteFileId、都不传 parentId。
|
||||
func TestCrossPlatformCoverageDrivePush_overwriteUsesOverwriteFileId(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "new-content")
|
||||
|
||||
caller := &driveSyncMockCaller{
|
||||
// 远端根目录下已存在同名 a.txt,fileId=REMOTE_FID。
|
||||
listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"REMOTE_FID","md5":"x","modifyTime":1000}],"nextToken":""}}`,
|
||||
}
|
||||
if err := runDrivePushTest(t, caller, dir, "--if-exists", "overwrite"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
up := caller.callsFor("get_upload_info")
|
||||
if len(up) != 1 {
|
||||
t.Fatalf("expected 1 get_upload_info call, got %d", len(up))
|
||||
}
|
||||
if got := up[0].args["overwriteFileId"]; got != "REMOTE_FID" {
|
||||
t.Errorf("get_upload_info overwriteFileId = %v, want REMOTE_FID", got)
|
||||
}
|
||||
if _, ok := up[0].args["parentId"]; ok {
|
||||
t.Error("覆盖时 get_upload_info 不应传 parentId")
|
||||
}
|
||||
|
||||
commit := caller.callsFor("commit_upload")
|
||||
if len(commit) != 1 {
|
||||
t.Fatalf("expected 1 commit_upload call, got %d", len(commit))
|
||||
}
|
||||
if got := commit[0].args["overwriteFileId"]; got != "REMOTE_FID" {
|
||||
t.Errorf("commit_upload overwriteFileId = %v, want REMOTE_FID", got)
|
||||
}
|
||||
if _, ok := commit[0].args["parentId"]; ok {
|
||||
t.Error("覆盖时 commit_upload 不应传 parentId")
|
||||
}
|
||||
}
|
||||
|
||||
// 新建分支:远端不存在同名文件时走普通上传——两阶段都传 parentId、都不传 overwriteFileId。
|
||||
func TestCrossPlatformCoverageDrivePush_newUploadUsesParentId(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "b.txt"), "brand-new")
|
||||
|
||||
caller := &driveSyncMockCaller{
|
||||
listJSON: `{"result":{"items":[],"nextToken":""}}`, // 远端空
|
||||
}
|
||||
if err := runDrivePushTest(t, caller, dir, "--if-exists", "overwrite"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
up := caller.callsFor("get_upload_info")
|
||||
if len(up) != 1 {
|
||||
t.Fatalf("expected 1 get_upload_info call, got %d", len(up))
|
||||
}
|
||||
if _, ok := up[0].args["overwriteFileId"]; ok {
|
||||
t.Error("新建上传不应传 overwriteFileId")
|
||||
}
|
||||
if got := up[0].args["parentId"]; got != "ROOT" {
|
||||
t.Errorf("get_upload_info parentId = %v, want ROOT", got)
|
||||
}
|
||||
commit := caller.callsFor("commit_upload")
|
||||
if len(commit) != 1 || commit[0].args["parentId"] != "ROOT" {
|
||||
t.Errorf("commit_upload should carry parentId=ROOT, got %v", commit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushRejectsUnknownCommitEffect(t *testing.T) {
|
||||
for _, response := range []string{"", `{}`} {
|
||||
t.Run(fmt.Sprintf("response-%q", response), func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "payload")
|
||||
caller := &driveSyncMockCaller{
|
||||
listJSON: `{"result":{"items":[]}}`,
|
||||
commitJSON: &response,
|
||||
}
|
||||
err, output, putCalls := runDriveMirrorUploadCommandCapture(t, "push", caller, dir)
|
||||
if err == nil {
|
||||
t.Fatal("empty commit result must make push exit non-zero")
|
||||
}
|
||||
if failure, ok := err.(*drivePushFailure); !ok || failure.ExitCode() != 1 {
|
||||
t.Fatalf("error=%T %v", err, err)
|
||||
}
|
||||
assertMirrorUploadFailedJSON(t, output)
|
||||
if putCalls != 1 {
|
||||
t.Fatalf("OSS PUT calls=%d, want exactly 1", putCalls)
|
||||
}
|
||||
if got := len(caller.callsFor("get_upload_info")); got != 1 {
|
||||
t.Fatalf("get_upload_info calls=%d, want 1", got)
|
||||
}
|
||||
if got := len(caller.callsFor("commit_upload")); got != 1 {
|
||||
t.Fatalf("commit_upload calls=%d, want 1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// replacePinnedMirrorRoot 返回 true 表示原目录被真实移走、原名处已换成另一个目录;
|
||||
// 返回 false 表示走了 Windows 身份注入降级,原目录仍留在原处。依赖「替代目录内容」
|
||||
// 的断言必须按返回值分流。
|
||||
func replacePinnedMirrorRoot(t *testing.T, root, moved string) bool {
|
||||
t.Helper()
|
||||
if forcePinnedFallbackForTest {
|
||||
swapPinnedRootIdentity(t, root)
|
||||
return false
|
||||
}
|
||||
if err := os.Rename(root, moved); err != nil {
|
||||
// Windows 在目录内有已打开的句柄(如上传读取中的源文件)时会锁住该目录,
|
||||
// 移走固定根于该平台物理不可达。退化为等价的根身份注入,命中同一条
|
||||
// fail-closed 分支。
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Fatalf("move pinned root: %v", err)
|
||||
}
|
||||
swapPinnedRootIdentity(t, root)
|
||||
return false
|
||||
}
|
||||
if err := os.Mkdir(root, 0o755); err != nil {
|
||||
t.Fatalf("create replacement root: %v", err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "outside-replacement")
|
||||
return true
|
||||
}
|
||||
|
||||
// 本地扫描完成后、任何 create_folder/get_upload_info/commit_upload 前若命令行根已
|
||||
// 被另一目录替换,push 与 sync 都必须整批失败,不能把替代目录当成扫描快照继续写远端。
|
||||
func TestCrossPlatformCoverageDrivePushSyncRejectRootReplacementBeforeRemoteWrite(t *testing.T) {
|
||||
for _, command := range []string{"push", "sync"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "root")
|
||||
moved := filepath.Join(parent, "moved-original")
|
||||
if err := os.Mkdir(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "pinned-original")
|
||||
|
||||
replaced := false
|
||||
realMove := false
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool == "list_files" {
|
||||
if !replaced {
|
||||
realMove = replacePinnedMirrorRoot(t, root, moved)
|
||||
replaced = true
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"fileId":"UNEXPECTED"},"success":true}`, nil
|
||||
}}
|
||||
putCalls := 0
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
putCalls++
|
||||
return nil
|
||||
})
|
||||
|
||||
args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"}
|
||||
if command == "sync" {
|
||||
args = append(args, "--quick")
|
||||
}
|
||||
err := runDriveCmd(t, caller, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "本地根目录在同步期间被替换") {
|
||||
t.Fatalf("expected root replacement rejection, got %v", err)
|
||||
}
|
||||
for _, tool := range []string{"create_folder", "get_upload_info", "commit_upload"} {
|
||||
if got := caller.callsFor(tool); len(got) != 0 {
|
||||
t.Errorf("%s must not run after root replacement: %v", tool, got)
|
||||
}
|
||||
}
|
||||
if putCalls != 0 {
|
||||
t.Fatalf("OSS PUT must not run after root replacement, got %d", putCalls)
|
||||
}
|
||||
want := "outside-replacement"
|
||||
if !realMove {
|
||||
// 身份注入降级:原目录仍在原处,内容同样不得被镜像流程改写。
|
||||
want = "pinned-original"
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(root, "a.txt")); err != nil || string(b) != want {
|
||||
t.Fatalf("replacement tree changed: %q err=%v", string(b), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 即使根在 get_upload_info 之后、OSS PUT 读取前被替换,PUT 也只能读取扫描时固定根
|
||||
// 打开的文件句柄。传输后身份复核会拒绝 commit,替代目录内容绝不能泄漏到上传流。
|
||||
func TestCrossPlatformCoverageDrivePushSyncUploadReadsPinnedFileHandle(t *testing.T) {
|
||||
for _, command := range []string{"push", "sync"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "root")
|
||||
moved := filepath.Join(parent, "moved-original")
|
||||
if err := os.Mkdir(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "pinned-original")
|
||||
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
switch tool {
|
||||
case "list_files":
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
case "get_upload_info":
|
||||
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
||||
}
|
||||
return `{"result":{"fileId":"NEW"},"success":true}`, nil
|
||||
}}
|
||||
|
||||
var uploaded string
|
||||
putCalls := 0
|
||||
realMove := false
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(_ context.Context, _ string, _ map[string]string, file *os.File, _ int64) error {
|
||||
putCalls++
|
||||
realMove = replacePinnedMirrorRoot(t, root, moved)
|
||||
body, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploaded = string(body)
|
||||
return nil
|
||||
})
|
||||
|
||||
args := []string{command, "--local-folder", root, "--remote-folder", "ROOT"}
|
||||
if command == "sync" {
|
||||
args = append(args, "--quick")
|
||||
}
|
||||
err := runDriveCmd(t, caller, args...)
|
||||
if err == nil {
|
||||
t.Fatal("root replacement during PUT must prevent commit")
|
||||
}
|
||||
if putCalls != 1 || uploaded != "pinned-original" {
|
||||
t.Fatalf("PUT read %q in %d call(s), want pinned-original once", uploaded, putCalls)
|
||||
}
|
||||
if commits := caller.callsFor("commit_upload"); len(commits) != 0 {
|
||||
t.Fatalf("root replacement must prevent commit_upload: %v", commits)
|
||||
}
|
||||
if realMove {
|
||||
if b, readErr := os.ReadFile(filepath.Join(root, "a.txt")); readErr != nil || string(b) != "outside-replacement" {
|
||||
t.Fatalf("replacement file was read or changed: %q err=%v", string(b), readErr)
|
||||
}
|
||||
if b, readErr := os.ReadFile(filepath.Join(moved, "a.txt")); readErr != nil || string(b) != "pinned-original" {
|
||||
t.Fatalf("pinned original changed: %q err=%v", string(b), readErr)
|
||||
}
|
||||
} else if b, readErr := os.ReadFile(filepath.Join(root, "a.txt")); readErr != nil || string(b) != "pinned-original" {
|
||||
// 身份注入降级:原目录仍在原处,内容同样不得被上传路径改写。
|
||||
t.Fatalf("pinned original changed: %q err=%v", string(b), readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushDryRun_typeConflictsFail(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteFiles map[string]*remoteFile
|
||||
remoteFolders map[string]string
|
||||
localDirs []string
|
||||
localFiles []localPushFile
|
||||
}{
|
||||
{
|
||||
name: "remote file conflicts with local folder",
|
||||
remoteFiles: map[string]*remoteFile{"conflict": {RelPath: "conflict", FileID: "REMOTE_FILE"}},
|
||||
remoteFolders: map[string]string{"": "ROOT"},
|
||||
localDirs: []string{"conflict"},
|
||||
},
|
||||
{
|
||||
name: "remote folder conflicts with local file",
|
||||
remoteFiles: map[string]*remoteFile{},
|
||||
remoteFolders: map[string]string{"": "ROOT", "conflict": "REMOTE_FOLDER"},
|
||||
localFiles: []localPushFile{{RelPath: "conflict", Size: 1}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out.Reset()
|
||||
if err := printDrivePushDryRun(ifExistsSkip, tt.remoteFiles, tt.remoteFolders, tt.localDirs, tt.localFiles); err != nil {
|
||||
t.Fatalf("printDrivePushDryRun: %v", err)
|
||||
}
|
||||
|
||||
var got drivePushDryRunResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run result: %v\n%s", err, out.String())
|
||||
}
|
||||
if got.Plan.Summary.Failed != 1 || got.Plan.Summary.Uploaded != 0 {
|
||||
t.Fatalf("summary = %+v, want failed=1 uploaded=0", got.Plan.Summary)
|
||||
}
|
||||
if len(got.Plan.Items) != 1 || got.Plan.Items[0].Action != pushActionFailed || got.Plan.Items[0].Error == "" {
|
||||
t.Fatalf("items = %+v, want one failed item with an error", got.Plan.Items)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePush_typeConflictsFailBeforeWrites(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(*testing.T, string)
|
||||
remoteItem string
|
||||
}{
|
||||
{
|
||||
name: "remote file conflicts with local folder",
|
||||
prepare: func(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if err := os.Mkdir(filepath.Join(root, "conflict"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
remoteItem: `{"name":"conflict","type":"file","fileId":"REMOTE_FILE"}`,
|
||||
},
|
||||
{
|
||||
name: "remote folder conflicts with local file",
|
||||
prepare: func(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
mustWrite(t, filepath.Join(root, "conflict"), "local")
|
||||
},
|
||||
remoteItem: `{"name":"conflict","type":"folder","fileId":"REMOTE_FOLDER"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tt.prepare(t, root)
|
||||
withNoopPut(t)
|
||||
|
||||
caller := pushOKCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[` + tt.remoteItem + `],"nextToken":""}}`,
|
||||
})
|
||||
err := runDriveCmd(t, caller, "push", "--local-folder", root, "--remote-folder", "ROOT")
|
||||
var failure *drivePushFailure
|
||||
if !errors.As(err, &failure) || failure.failed != 1 {
|
||||
t.Fatalf("error = %T %v, want drivePushFailure(failed=1)", err, err)
|
||||
}
|
||||
if calls := caller.callsFor("create_folder"); len(calls) != 0 {
|
||||
t.Fatalf("type conflict must not create folders, got %v", calls)
|
||||
}
|
||||
if calls := caller.callsFor("get_upload_info"); len(calls) != 0 {
|
||||
t.Fatalf("type conflict must not upload, got %v", calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDriveRemoteTree_duplicatePathsFail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reply func(string, map[string]any, int) (string, error)
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "same file path across pages",
|
||||
path: "duplicate.txt",
|
||||
reply: func(_ string, args map[string]any, _ int) (string, error) {
|
||||
if _, ok := args["nextToken"]; !ok {
|
||||
return `{"result":{"items":[{"name":"duplicate.txt","type":"file","fileId":"F1"}],"nextToken":"PAGE2"}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[{"name":"duplicate.txt","type":"file","fileId":"F2"}],"nextToken":""}}`, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "same folder path with duplicate descendants",
|
||||
path: "duplicate",
|
||||
reply: func(_ string, args map[string]any, _ int) (string, error) {
|
||||
switch args["parentId"] {
|
||||
case "ROOT":
|
||||
return `{"result":{"items":[` +
|
||||
`{"name":"duplicate","type":"folder","fileId":"D1"},` +
|
||||
`{"name":"duplicate","type":"folder","fileId":"D2"}` +
|
||||
`],"nextToken":""}}`, nil
|
||||
case "D1":
|
||||
return `{"result":{"items":[{"name":"child.txt","type":"file","fileId":"C1"}],"nextToken":""}}`, nil
|
||||
case "D2":
|
||||
return `{"result":{"items":[{"name":"child.txt","type":"file","fileId":"C2"}],"nextToken":""}}`, nil
|
||||
default:
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fetchers := []struct {
|
||||
name string
|
||||
fetch func(context.Context) error
|
||||
}{
|
||||
{
|
||||
name: "status and pull tree",
|
||||
fetch: func(ctx context.Context) error {
|
||||
_, err := fetchRemoteDriveTree(ctx, "", "ROOT", true)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push and sync tree",
|
||||
fetch: func(ctx context.Context) error {
|
||||
_, _, err := fetchRemoteTreeForPush(ctx, "", "ROOT")
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, fetcher := range fetchers {
|
||||
t.Run(fetcher.name, func(t *testing.T) {
|
||||
caller := &driveScriptCaller{reply: tt.reply}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
err := fetcher.fetch(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "重复远端路径") || !strings.Contains(err.Error(), tt.path) {
|
||||
t.Fatalf("error = %v, want duplicate remote path %q", err, tt.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDriveRemoteTree_folderIDFallbacks(t *testing.T) {
|
||||
for _, key := range []string{"fileId", "dentryUuid", "dentryId", "id", "nodeId"} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
caller := &driveScriptCaller{reply: func(_ string, args map[string]any, _ int) (string, error) {
|
||||
if args["parentId"] == "ROOT" {
|
||||
return `{"result":{"items":[{"name":"sub","type":"folder","` + key + `":"SUB"}],"nextToken":""}}`, nil
|
||||
}
|
||||
if args["parentId"] != "SUB" {
|
||||
t.Fatalf("nested parentId = %v, want SUB", args["parentId"])
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
if _, err := fetchRemoteDriveTree(context.Background(), "", "ROOT", true); err != nil {
|
||||
t.Fatalf("fetch with %s: %v", key, err)
|
||||
}
|
||||
if got := len(caller.callsFor("list_files")); got != 2 {
|
||||
t.Fatalf("list_files calls = %d, want root + nested", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveRemoteTree_missingFolderIDFailsClosed(t *testing.T) {
|
||||
for _, fetcher := range []struct {
|
||||
name string
|
||||
fetch func(context.Context) error
|
||||
}{
|
||||
{
|
||||
name: "status and pull tree",
|
||||
fetch: func(ctx context.Context) error {
|
||||
_, err := fetchRemoteDriveTree(ctx, "", "ROOT", true)
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push and sync tree",
|
||||
fetch: func(ctx context.Context) error {
|
||||
_, _, err := fetchRemoteTreeForPush(ctx, "", "ROOT")
|
||||
return err
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fetcher.name, func(t *testing.T) {
|
||||
caller := &driveScriptCaller{reply: func(_ string, args map[string]any, nth int) (string, error) {
|
||||
if nth > 0 || args["parentId"] != "ROOT" {
|
||||
t.Fatalf("missing folder ID must not trigger another list_files call: nth=%d args=%v", nth, args)
|
||||
}
|
||||
return `{"result":{"items":[{"name":"sub","type":"folder"}],"nextToken":""}}`, nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
err := fetcher.fetch(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "目录 ID") || !strings.Contains(err.Error(), "sub") {
|
||||
t.Fatalf("error = %v, want missing folder ID for sub", err)
|
||||
}
|
||||
if got := len(caller.callsFor("list_files")); got != 1 {
|
||||
t.Fatalf("list_files calls = %d, want only root query", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageParseDriveList_requiresAuthoritativeResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantError string
|
||||
wantItems int
|
||||
}{
|
||||
{name: "missing result", body: `{}`, wantError: "缺少 result"},
|
||||
{name: "null result", body: `{"result":null}`, wantError: "result 为 null"},
|
||||
{name: "empty result object", body: `{"result":{}}`, wantError: "缺少 items"},
|
||||
{name: "null item", body: `{"result":{"items":[null]}}`, wantError: "缺少有效 type"},
|
||||
{name: "empty item object", body: `{"result":{"items":[{}]}}`, wantError: "缺少有效 type"},
|
||||
{name: "item missing type", body: `{"result":{"items":[{"name":"unknown"}]}}`, wantError: "缺少有效 type"},
|
||||
{name: "item empty type", body: `{"result":{"items":[{"name":"unknown","type":" "}]}}`, wantError: "缺少有效 type"},
|
||||
{name: "has more without token", body: `{"result":{"items":[],"hasMore":true,"nextToken":""}}`, wantError: "hasMore=true"},
|
||||
{name: "empty items", body: `{"result":{"items":[]}}`},
|
||||
{name: "empty result array", body: `{"result":[]}`},
|
||||
{name: "explicit unsupported type remains parseable", body: `{"result":{"items":[{"name":"online.adoc","type":"document"}]}}`, wantItems: 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
items, token, err := parseDriveList(tt.body)
|
||||
if tt.wantError != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
|
||||
t.Fatalf("parseDriveList error = %v, want containing %q", err, tt.wantError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseDriveList(%s): %v", tt.body, err)
|
||||
}
|
||||
if len(items) != tt.wantItems || token != "" {
|
||||
t.Fatalf("parseDriveList(%s) = (%v, %q), want %d authoritative item(s) with no token", tt.body, items, token, tt.wantItems)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveRemoteTree_integrityFailuresStopFourCommandsBeforeWrites(t *testing.T) {
|
||||
failures := []struct {
|
||||
name string
|
||||
wantError string
|
||||
wantListCalls int
|
||||
reply func(int) string
|
||||
}{
|
||||
{
|
||||
name: "missing result",
|
||||
wantError: "缺少 result",
|
||||
wantListCalls: 1,
|
||||
reply: func(int) string { return `{}` },
|
||||
},
|
||||
{
|
||||
name: "null result",
|
||||
wantError: "result 为 null",
|
||||
wantListCalls: 1,
|
||||
reply: func(int) string { return `{"result":null}` },
|
||||
},
|
||||
{
|
||||
name: "empty result object",
|
||||
wantError: "缺少 items",
|
||||
wantListCalls: 1,
|
||||
reply: func(int) string { return `{"result":{}}` },
|
||||
},
|
||||
{
|
||||
name: "null item",
|
||||
wantError: "缺少有效 type",
|
||||
wantListCalls: 1,
|
||||
reply: func(int) string { return `{"result":{"items":[null],"nextToken":""}}` },
|
||||
},
|
||||
{
|
||||
name: "empty item object",
|
||||
wantError: "缺少有效 type",
|
||||
wantListCalls: 1,
|
||||
reply: func(int) string { return `{"result":{"items":[{}],"nextToken":""}}` },
|
||||
},
|
||||
{
|
||||
name: "item missing type",
|
||||
wantError: "缺少有效 type",
|
||||
wantListCalls: 1,
|
||||
reply: func(int) string {
|
||||
return `{"result":{"items":[{"name":"unknown"}],"nextToken":""}}`
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "item empty type",
|
||||
wantError: "缺少有效 type",
|
||||
wantListCalls: 1,
|
||||
reply: func(int) string {
|
||||
return `{"result":{"items":[{"name":"unknown","type":""}],"nextToken":""}}`
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "has more without token",
|
||||
wantError: "hasMore=true",
|
||||
wantListCalls: 1,
|
||||
reply: func(int) string {
|
||||
return `{"result":{"items":[],"hasMore":true,"nextToken":""}}`
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file without an ID",
|
||||
wantError: "缺少文件 ID",
|
||||
wantListCalls: 1,
|
||||
reply: func(int) string {
|
||||
return `{"result":{"items":[{"name":"remote.txt","type":"file"}],"nextToken":""}}`
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file name with an ASCII control",
|
||||
wantError: "无法安全映射到本地路径",
|
||||
wantListCalls: 1,
|
||||
reply: func(int) string {
|
||||
return `{"result":{"items":[{"name":"remote\u0000.txt","type":"file","fileId":"REMOTE"}],"nextToken":""}}`
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pagination token cycle",
|
||||
wantError: "分页 token",
|
||||
wantListCalls: 3,
|
||||
reply: func(nth int) string {
|
||||
tokens := []string{"PAGE_A", "PAGE_B", "PAGE_A"}
|
||||
if nth >= len(tokens) {
|
||||
return `{"result":{"items":[],"nextToken":""}}`
|
||||
}
|
||||
return fmt.Sprintf(`{"result":{"items":[],"nextToken":%q}}`, tokens[nth])
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
commands := []struct {
|
||||
name string
|
||||
args func(string) []string
|
||||
prepare func(*testing.T, string)
|
||||
}{
|
||||
{
|
||||
name: "status",
|
||||
args: func(localRoot string) []string {
|
||||
return []string{"status", "--local-folder", localRoot, "--remote-folder", "ROOT", "--quick"}
|
||||
},
|
||||
prepare: prepareRemoteTreeReadRoot,
|
||||
},
|
||||
{
|
||||
name: "pull",
|
||||
args: func(localRoot string) []string {
|
||||
return []string{"pull", "--local-folder", localRoot, "--remote-folder", "ROOT"}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push",
|
||||
args: func(localRoot string) []string {
|
||||
return []string{"push", "--local-folder", localRoot, "--remote-folder", "ROOT"}
|
||||
},
|
||||
prepare: prepareRemoteTreeWriteSource,
|
||||
},
|
||||
{
|
||||
name: "sync",
|
||||
args: func(localRoot string) []string {
|
||||
return []string{"sync", "--local-folder", localRoot, "--remote-folder", "ROOT", "--quick"}
|
||||
},
|
||||
prepare: prepareRemoteTreeWriteSource,
|
||||
},
|
||||
}
|
||||
|
||||
for _, failure := range failures {
|
||||
t.Run(failure.name, func(t *testing.T) {
|
||||
for _, command := range commands {
|
||||
t.Run(command.name, func(t *testing.T) {
|
||||
localRoot := filepath.Join(t.TempDir(), "mirror")
|
||||
if command.prepare != nil {
|
||||
command.prepare(t, localRoot)
|
||||
}
|
||||
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
return "", fmt.Errorf("远端清单预检完成前调用了写工具 %s", tool)
|
||||
}
|
||||
return failure.reply(nth), nil
|
||||
}}
|
||||
getCalls, putCalls := swapRemoteTreeTransferCounters(t)
|
||||
|
||||
err := runDriveCmd(t, caller, command.args(localRoot)...)
|
||||
if err == nil || !strings.Contains(err.Error(), failure.wantError) {
|
||||
t.Fatalf("error = %v, want remote tree integrity failure containing %q", err, failure.wantError)
|
||||
}
|
||||
if got := len(caller.callsFor("list_files")); got != failure.wantListCalls {
|
||||
t.Fatalf("list_files calls = %d, want %d", got, failure.wantListCalls)
|
||||
}
|
||||
for _, call := range caller.calls {
|
||||
if call.toolName != "list_files" {
|
||||
t.Fatalf("remote tree integrity failure must stop before MCP writes, got %s", call.toolName)
|
||||
}
|
||||
}
|
||||
if *getCalls != 0 || *putCalls != 0 {
|
||||
t.Fatalf("remote tree integrity failure must stop before transfers: GET=%d PUT=%d", *getCalls, *putCalls)
|
||||
}
|
||||
if command.name == "pull" {
|
||||
if _, statErr := os.Stat(localRoot); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("pull must fail before creating its local root: %v", statErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveRemoteTree_validEmptyListsRemainSupported(t *testing.T) {
|
||||
for _, listing := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "items object", body: `{"result":{"items":[]}}`},
|
||||
{name: "result array", body: `{"result":[]}`},
|
||||
} {
|
||||
t.Run(listing.name, func(t *testing.T) {
|
||||
for _, fetcher := range []struct {
|
||||
name string
|
||||
fetch func(context.Context) error
|
||||
}{
|
||||
{
|
||||
name: "status and pull walker",
|
||||
fetch: func(ctx context.Context) error {
|
||||
files, err := fetchRemoteDriveTree(ctx, "", "ROOT", true)
|
||||
if len(files) != 0 {
|
||||
return fmt.Errorf("remote files = %v, want empty", files)
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push and sync walker",
|
||||
fetch: func(ctx context.Context) error {
|
||||
files, folders, err := fetchRemoteTreeForPush(ctx, "", "ROOT")
|
||||
if len(files) != 0 || len(folders) != 1 || folders[""] != "ROOT" {
|
||||
return fmt.Errorf("remote tree = files %v folders %v, want empty root", files, folders)
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fetcher.name, func(t *testing.T) {
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
t.Fatalf("unexpected tool %s", tool)
|
||||
}
|
||||
return listing.body, nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
if err := fetcher.fetch(context.Background()); err != nil {
|
||||
t.Fatalf("valid empty listing: %v", err)
|
||||
}
|
||||
if got := len(caller.callsFor("list_files")); got != 1 {
|
||||
t.Fatalf("list_files calls = %d, want 1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsSafeRemoteSegment_rejectsASCIIControls(t *testing.T) {
|
||||
for _, name := range []string{"nul\x00name", "unit\x1fseparator", "delete\x7fcharacter"} {
|
||||
if isSafeRemoteSegment(name) {
|
||||
t.Errorf("isSafeRemoteSegment(%q) = true, want false", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func prepareRemoteTreeReadRoot(t *testing.T, localRoot string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(localRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareRemoteTreeWriteSource(t *testing.T, localRoot string) {
|
||||
t.Helper()
|
||||
mustWrite(t, filepath.Join(localRoot, "local.txt"), "local")
|
||||
}
|
||||
|
||||
func swapRemoteTreeTransferCounters(t *testing.T) (*int, *int) {
|
||||
t.Helper()
|
||||
getCalls, putCalls := 0, 0
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
getCalls++
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
putCalls++
|
||||
return nil
|
||||
})
|
||||
return &getCalls, &putCalls
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDriveRemoteTree_unsafeNamesFailClosed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
listing string
|
||||
wantKind string
|
||||
}{
|
||||
{
|
||||
name: "file name containing a backslash",
|
||||
listing: `{"result":{"items":[{"name":"a\\b.txt","type":"file","fileId":"UNSAFE_FILE"}],"nextToken":""}}`,
|
||||
wantKind: "文件",
|
||||
},
|
||||
{
|
||||
name: "folder name escaping its parent",
|
||||
listing: `{"result":{"items":[{"name":"..","type":"folder","fileId":"UNSAFE_FOLDER"}],"nextToken":""}}`,
|
||||
wantKind: "目录",
|
||||
},
|
||||
}
|
||||
|
||||
commands := []struct {
|
||||
name string
|
||||
args func(localRoot string) []string
|
||||
prepare func(t *testing.T, localRoot string)
|
||||
}{
|
||||
{
|
||||
name: "status",
|
||||
args: func(localRoot string) []string {
|
||||
return []string{"status", "--local-folder", localRoot, "--remote-folder", "ROOT", "--quick"}
|
||||
},
|
||||
prepare: func(t *testing.T, localRoot string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(localRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pull",
|
||||
args: func(localRoot string) []string {
|
||||
return []string{"pull", "--local-folder", localRoot, "--remote-folder", "ROOT"}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "push",
|
||||
args: func(localRoot string) []string {
|
||||
return []string{"push", "--local-folder", localRoot, "--remote-folder", "ROOT"}
|
||||
},
|
||||
prepare: prepareUnsafeNameWriteSource,
|
||||
},
|
||||
{
|
||||
name: "sync",
|
||||
args: func(localRoot string) []string {
|
||||
return []string{"sync", "--local-folder", localRoot, "--remote-folder", "ROOT", "--quick"}
|
||||
},
|
||||
prepare: prepareUnsafeNameWriteSource,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, command := range commands {
|
||||
t.Run(command.name, func(t *testing.T) {
|
||||
localRoot := filepath.Join(t.TempDir(), "mirror")
|
||||
if command.prepare != nil {
|
||||
command.prepare(t, localRoot)
|
||||
}
|
||||
|
||||
caller := &driveScriptCaller{reply: func(tool string, args map[string]any, nth int) (string, error) {
|
||||
if tool != "list_files" || nth != 0 || args["parentId"] != "ROOT" {
|
||||
t.Fatalf("unsafe remote entry must stop before recursion: tool=%s nth=%d args=%v", tool, nth, args)
|
||||
}
|
||||
return tt.listing, nil
|
||||
}}
|
||||
getCalls, putCalls := 0, 0
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
getCalls++
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
putCalls++
|
||||
return nil
|
||||
})
|
||||
|
||||
err := runDriveCmd(t, caller, command.args(localRoot)...)
|
||||
if err == nil || !strings.Contains(err.Error(), "无法安全映射到本地路径") ||
|
||||
!strings.Contains(err.Error(), tt.wantKind) {
|
||||
t.Fatalf("error = %v, want unsafe remote %s name failure", err, tt.wantKind)
|
||||
}
|
||||
if got := len(caller.callsFor("list_files")); got != 1 {
|
||||
t.Fatalf("list_files calls = %d, want only the root query", got)
|
||||
}
|
||||
for _, tool := range []string{"create_folder", "download_file", "get_upload_info", "commit_upload"} {
|
||||
if calls := caller.callsFor(tool); len(calls) != 0 {
|
||||
t.Fatalf("unsafe remote name must not call %s: %v", tool, calls)
|
||||
}
|
||||
}
|
||||
if getCalls != 0 || putCalls != 0 {
|
||||
t.Fatalf("unsafe remote name must not transfer content: GET=%d PUT=%d", getCalls, putCalls)
|
||||
}
|
||||
if command.name == "pull" {
|
||||
if _, statErr := os.Stat(localRoot); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("pull must fail before creating its local root: %v", statErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func prepareUnsafeNameWriteSource(t *testing.T, localRoot string) {
|
||||
t.Helper()
|
||||
mustWrite(t, filepath.Join(localRoot, "local.txt"), "local")
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
import "os"
|
||||
|
||||
// driveReplaceFile 在同一文件系统内以 source 替换既有非目录 target。
|
||||
// Unix 的 rename(2) 原生提供原子替换语义。
|
||||
func driveReplaceFile(source, target string) error {
|
||||
return os.Rename(source, target)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Unix 的 rename(2) 原生提供原子替换语义;该路径与 Windows 的
|
||||
// MOVEFILE_REPLACE_EXISTING 同样服务 pull overwrite、sync remote-wins 与
|
||||
// keep-both 的占位文件替换,因此两侧都需要平台覆盖。
|
||||
func TestCrossPlatformCoverageDriveReplaceFileUnix_replacesExistingTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source.tmp")
|
||||
target := filepath.Join(dir, "target.txt")
|
||||
if err := os.WriteFile(source, []byte("new"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := driveReplaceFile(source, target); err != nil {
|
||||
t.Fatalf("replace existing target: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
|
||||
t.Fatalf("target = %q, err=%v; want new content", got, err)
|
||||
}
|
||||
if _, err := os.Stat(source); !os.IsNotExist(err) {
|
||||
t.Fatalf("source must be moved, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build windows
|
||||
|
||||
package helpers
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// driveReplaceFile 使用 MoveFileEx(MOVEFILE_REPLACE_EXISTING) 替换既有非目录目标。
|
||||
// 不使用“先删除再改名”,避免覆盖过程中出现目标暂时缺失或原文件丢失。
|
||||
func driveReplaceFile(source, target string) error {
|
||||
return windows.Rename(source, target)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Windows 必须通过 MOVEFILE_REPLACE_EXISTING 原子替换既有非目录目标;该路径同时
|
||||
// 服务 pull overwrite、sync remote-wins 与 keep-both 的占位文件替换。
|
||||
func TestCrossPlatformCoverageDriveReplaceFileWindows_replacesExistingTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source.tmp")
|
||||
target := filepath.Join(dir, "target.txt")
|
||||
if err := os.WriteFile(source, []byte("new"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("old"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := driveReplaceFile(source, target); err != nil {
|
||||
t.Fatalf("replace existing target: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
|
||||
t.Fatalf("target = %q, err=%v; want new content", got, err)
|
||||
}
|
||||
if _, err := os.Stat(source); !os.IsNotExist(err) {
|
||||
t.Fatalf("source must be moved, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ==========================================================
|
||||
// drive status — 比较本地文件夹与钉盘文件夹的差异
|
||||
// ==========================================================
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws drive status — 比较本地文件夹与钉盘文件夹的差异
|
||||
//
|
||||
// 只读命令:本地取 --local-folder(绝对路径),钉盘取 --remote-folder
|
||||
// (文件夹 dentryUuid)指向的文件夹,按精确 MD5(默认)或快速 modified_time
|
||||
// (--quick)逐文件比对,输出 new_local / new_remote / modified / unchanged /
|
||||
// unknown 五类差异(exact 模式远端无可靠 md5 的文件归入 unknown)。两侧各自递归
|
||||
// 遍历,rel_path 相对各自根目录。
|
||||
//
|
||||
// 后端交互(按 parentId 递归拉取 list_files、只保留 type=file 的二进制文件、并为
|
||||
// 每个条目填充 hash 或者 modified_time),见 fetchRemoteDriveTree。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// driveStatusEntry 是输出 schema 中每个差异项的形态:{"rel_path": "..."}。
|
||||
type driveStatusEntry struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
}
|
||||
|
||||
// driveStatusResult 是 status 命令成功时的输出 schema。
|
||||
// 五个切片都初始化为非 nil,保证 JSON 序列化为 [] 而不是 null。
|
||||
//
|
||||
// unknown:exact 模式下双端都存在、但远端未返回可靠 md5 的文件——此时无法核对
|
||||
// 内容,既不能判 unchanged(会误报未变更),也不宜硬判 modified(会误报变更),
|
||||
// 如实归入 unknown,让降级模式在输出里显式可见。quick 模式不会产生 unknown。
|
||||
type driveStatusResult struct {
|
||||
Detection string `json:"detection"`
|
||||
NewLocal []driveStatusEntry `json:"new_local"`
|
||||
NewRemote []driveStatusEntry `json:"new_remote"`
|
||||
Modified []driveStatusEntry `json:"modified"`
|
||||
Unchanged []driveStatusEntry `json:"unchanged"`
|
||||
Unknown []driveStatusEntry `json:"unknown"`
|
||||
}
|
||||
|
||||
// localFile 描述一个本地常规文件。
|
||||
type localFile struct {
|
||||
RelPath string
|
||||
AbsPath string // 绝对路径,用于按需计算 MD5
|
||||
Hash string // MD5(base64);仅在双端都存在且非 quick 模式时按需计算
|
||||
Size int64 // 本地文件字节数
|
||||
ModTimeMillis int64 // 本地 mtime,Unix 毫秒
|
||||
}
|
||||
|
||||
// remoteFile 描述一个云端 type=file 的二进制文件。
|
||||
// 由 fetchRemoteDriveTree 填充。
|
||||
type remoteFile struct {
|
||||
RelPath string
|
||||
FileID string // dentryUuid;pull 下载时作为 download_file 的 fileId
|
||||
Hash string // 远端 md5(base64,exact 模式用;为空时该文件归入 unknown)
|
||||
Size int64 // 远端文件字节数
|
||||
ModifiedTime int64 // 远端 modified_time,Unix 毫秒(quick / smart 模式比较用)
|
||||
ModifiedTimeValid bool // 远端时间戳是否可信;不可信时走保守路径
|
||||
}
|
||||
|
||||
// 每次 list_files 请求的分页大小。后端约束 maxResults 必须在 1..50 之间。
|
||||
const driveListPageSize = 50
|
||||
|
||||
// remoteMaxDepth 是递归遍历子文件夹的最大层级,防止云端目录出现环形引用时死循环。
|
||||
const remoteMaxDepth = 64
|
||||
|
||||
// fetchRemoteDriveTree 递归拉取云端文件夹 parentID 下的文件树,返回所有 type=FILE 的
|
||||
// 二进制文件索引(key 为 rel_path)。spaceID 为空时省略 spaceId 参数,由 MCP Server
|
||||
// 默认到「我的文件」(与其它 drive 命令一致);parentID 为待比对的云端根文件夹 dentryUuid。
|
||||
//
|
||||
// 实现要点:
|
||||
// - 通过 drive 的 list_files MCP 工具,按 parentId(文件夹 dentryUuid)逐层遍历;
|
||||
// - 单个目录文件过多时按 nextToken 分批拉取(maxResults ≤ 50);
|
||||
// - 遇到子文件夹用其 dentryUuid 作为下一层的 parentId 递归进入;
|
||||
// - rel_path 由各层文件夹名逐层拼接得到(相对 parentID 根),与本地相对路径对齐;
|
||||
// - 只纳入明确 type=FILE 的条目,其余类型(在线文档、快捷方式等)一律跳过。
|
||||
func fetchRemoteDriveTree(ctx context.Context, spaceID, parentID string, quick bool) (map[string]*remoteFile, error) {
|
||||
out := make(map[string]*remoteFile)
|
||||
occupied := make(map[string]string)
|
||||
if err := walkRemoteDir(ctx, spaceID, parentID, "", out, occupied, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = quick // hash 与 modified_time 都会被填充,比对模式由 compareTrees 决定
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// walkRemoteDir 遍历 parentID 指向的文件夹,把其中的二进制文件写入 out(key 为 rel_path),
|
||||
// 并递归进入子文件夹。occupied 同时记录文件和目录;精确路径被第二个条目占用时立即失败,
|
||||
// 防止分页或返回顺序决定最终镜像。relBase 是当前文件夹相对遍历起点的路径前缀。
|
||||
func walkRemoteDir(ctx context.Context, spaceID, parentID, relBase string, out map[string]*remoteFile, occupied map[string]string, depth int) error {
|
||||
if depth > remoteMaxDepth {
|
||||
return fmt.Errorf("drive 目录层级超过 %d 层,疑似循环引用,已中止", remoteMaxDepth)
|
||||
}
|
||||
|
||||
nextToken := ""
|
||||
seenTokens := make(map[string]struct{})
|
||||
for {
|
||||
args := map[string]any{"maxResults": float64(driveListPageSize)}
|
||||
// space-id 为空则省略,交给 MCP Server 默认「我的文件」。
|
||||
if spaceID != "" {
|
||||
args["spaceId"] = spaceID
|
||||
}
|
||||
// 按 parentId(文件夹 dentryUuid)导航;空则由 MCP Server 默认列空间根目录。
|
||||
if parentID != "" {
|
||||
args["parentId"] = parentID
|
||||
}
|
||||
if nextToken != "" {
|
||||
args["nextToken"] = nextToken
|
||||
}
|
||||
|
||||
text, err := callDriveListFiles(ctx, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items, token, err := parseDriveList(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
name, included, err := remoteMirrorEntryName(it, relBase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !included {
|
||||
continue
|
||||
}
|
||||
// rel_path 由文件夹名逐层拼接,统一用 / 分隔,与本地相对路径对齐。
|
||||
childRel := name
|
||||
if relBase != "" {
|
||||
childRel = relBase + "/" + name
|
||||
}
|
||||
|
||||
if it.isFolder() {
|
||||
if err := claimRemotePath(occupied, childRel, "目录"); err != nil {
|
||||
return err
|
||||
}
|
||||
folderID := it.id()
|
||||
if folderID == "" {
|
||||
return fmt.Errorf("远端目录 %q 缺少目录 ID,已中止递归", childRel)
|
||||
}
|
||||
// 用子文件夹的 dentryUuid 作为下一层 parentId 递归进入。
|
||||
if err := walkRemoteDir(ctx, spaceID, folderID, childRel, out, occupied, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 非文件类型已由 remoteMirrorEntryName 过滤;目录在上方完成递归后返回。
|
||||
if err := claimRemotePath(occupied, childRel, "文件"); err != nil {
|
||||
return err
|
||||
}
|
||||
fileID := it.id()
|
||||
if fileID == "" {
|
||||
return fmt.Errorf("远端文件 %q 缺少文件 ID,已中止遍历", childRel)
|
||||
}
|
||||
|
||||
modMillis, modValid := it.modifiedMillis()
|
||||
out[childRel] = &remoteFile{
|
||||
RelPath: childRel,
|
||||
FileID: fileID,
|
||||
Hash: it.hash(),
|
||||
Size: it.size(),
|
||||
ModifiedTime: modMillis,
|
||||
ModifiedTimeValid: modValid,
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
break
|
||||
}
|
||||
if _, exists := seenTokens[token]; exists {
|
||||
return fmt.Errorf("远端目录 %q 的分页 token 重复,疑似分页循环,已中止", relBase)
|
||||
}
|
||||
seenTokens[token] = struct{}{}
|
||||
nextToken = token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// claimRemotePath 保证一棵远端树中的每个精确 rel_path 只对应一个文件或目录。
|
||||
// 重复条目会让本地镜像不可完整表达,因此必须失败,不能任意选择其中一个 fileId。
|
||||
func claimRemotePath(occupied map[string]string, rel, kind string) error {
|
||||
if previous, exists := occupied[rel]; exists {
|
||||
return fmt.Errorf("检测到重复远端路径 %q(已有%s,重复%s),无法构建确定性镜像", rel, previous, kind)
|
||||
}
|
||||
occupied[rel] = kind
|
||||
return nil
|
||||
}
|
||||
|
||||
// remoteMirrorEntryName 只选择文件夹镜像支持的 file / folder 条目,并验证其名称
|
||||
// 能安全、无歧义地表示为本地单层路径。无法表示的镜像条目必须中止整棵远端树构建;
|
||||
// 静默跳过会让 status/pull/push/sync 在遗漏文件或目录子树时误报成功。
|
||||
func remoteMirrorEntryName(it driveItem, relBase string) (string, bool, error) {
|
||||
kind := ""
|
||||
switch {
|
||||
case it.isFolder():
|
||||
kind = "目录"
|
||||
case it.isFile():
|
||||
kind = "文件"
|
||||
default:
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
name := it.name()
|
||||
if !isSafeRemoteSegment(name) {
|
||||
return "", false, fmt.Errorf("远端%s名称 %q 无法安全映射到本地路径(父路径 %q),已中止", kind, name, relBase)
|
||||
}
|
||||
return name, true, nil
|
||||
}
|
||||
|
||||
// callDriveListFiles 让文件夹同步族在 --dry-run 下仍可通过受控只读通道获取
|
||||
// 远端现状;普通 CallTool 在 dry-run 下只会返回执行计划,不能被当作业务数据解析。
|
||||
func callDriveListFiles(ctx context.Context, args map[string]any) (string, error) {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPReadToolReturnTextOnServer(ctx, "drive", "list_files", args)
|
||||
}
|
||||
return callMCPToolReturnText(ctx, "list_files", args)
|
||||
}
|
||||
|
||||
// driveItem 是 list_files 返回里的一个 dentry 条目。
|
||||
// 后端字段命名存在多种历史写法,这里用带 fallback 的访问器做防御式解析。
|
||||
type driveItem map[string]any
|
||||
|
||||
func (d driveItem) str(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if s, ok := d[k].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d driveItem) name() string { return d.str("name") }
|
||||
func (d driveItem) id() string { return d.str("fileId", "dentryUuid", "dentryId", "id", "nodeId") }
|
||||
func (d driveItem) typ() string { return d.str("type") }
|
||||
func (d driveItem) hash() string { return d.str("md5") }
|
||||
func (d driveItem) path() string { return d.str("path") }
|
||||
|
||||
// size 返回远端文件字节数(fileSize 字段),用于 exact 无 md5 时的降级比对。
|
||||
func (d driveItem) size() int64 {
|
||||
switch v := d["fileSize"].(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case json.Number:
|
||||
if n, err := v.Int64(); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d driveItem) isFolder() bool {
|
||||
return strings.ToUpper(d.typ()) == "FOLDER"
|
||||
}
|
||||
|
||||
func (d driveItem) isFile() bool {
|
||||
return strings.ToUpper(d.typ()) == "FILE"
|
||||
}
|
||||
|
||||
func (d driveItem) modifiedMillis() (int64, bool) {
|
||||
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
|
||||
if v, ok := d[k]; ok {
|
||||
if ms, ok := toMillis(v); ok {
|
||||
return ms, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// parseDriveList 解析 list_files 返回文本,抽出 items 列表与 nextToken。
|
||||
// 兼容两种 result 形态:
|
||||
// - {"result":{"items":[...],"nextToken":"..."}}
|
||||
// - {"result":[...]}(result 直接是条目数组,无分页)
|
||||
func parseDriveList(text string) ([]driveItem, string, error) {
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(text), &envelope); err != nil {
|
||||
return nil, "", fmt.Errorf("解析 list_files 返回失败: %w", err)
|
||||
}
|
||||
result, exists := envelope["result"]
|
||||
if !exists {
|
||||
return nil, "", fmt.Errorf("解析 list_files 返回失败: 缺少 result")
|
||||
}
|
||||
result = json.RawMessage(bytes.TrimSpace(result))
|
||||
if bytes.Equal(result, []byte("null")) {
|
||||
return nil, "", fmt.Errorf("解析 list_files 返回失败: result 为 null")
|
||||
}
|
||||
|
||||
toItems := func(ms []map[string]any) ([]driveItem, error) {
|
||||
items := make([]driveItem, 0, len(ms))
|
||||
for index, m := range ms {
|
||||
item := driveItem(m)
|
||||
// FILE / FOLDER 之外的显式类型(在线文档、快捷方式等)可以由镜像
|
||||
// walker 跳过;但 null、空对象或缺少有效 type 的条目不是一个可判定的
|
||||
// 业务对象。静默跳过会把不完整清单伪装成权威目录,必须失败关闭。
|
||||
if m == nil || strings.TrimSpace(item.typ()) == "" {
|
||||
return nil, fmt.Errorf("解析 list_files 返回失败: result.items[%d] 缺少有效 type", index)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(result) > 0 && result[0] == '{':
|
||||
// 形态一:result 是对象 {items, nextToken}。items 必须显式存在,不能把
|
||||
// 缺失/null 的非权威响应误判为空目录。
|
||||
var obj struct {
|
||||
Items json.RawMessage `json:"items"`
|
||||
NextToken string `json:"nextToken"`
|
||||
HasMore *bool `json:"hasMore"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &obj); err != nil {
|
||||
return nil, "", fmt.Errorf("解析 list_files 返回失败: %w", err)
|
||||
}
|
||||
if len(obj.Items) == 0 {
|
||||
return nil, "", fmt.Errorf("解析 list_files 返回失败: result 缺少 items")
|
||||
}
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal(obj.Items, &items); err != nil || items == nil {
|
||||
return nil, "", fmt.Errorf("解析 list_files 返回失败: result.items 不是数组")
|
||||
}
|
||||
if obj.HasMore != nil && *obj.HasMore && obj.NextToken == "" {
|
||||
return nil, "", fmt.Errorf("解析 list_files 返回失败: hasMore=true 但 nextToken 为空,远端清单可能被截断")
|
||||
}
|
||||
parsed, err := toItems(items)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return parsed, obj.NextToken, nil
|
||||
case len(result) > 0 && result[0] == '[':
|
||||
// 形态二:result 直接是条目数组。
|
||||
var arr []map[string]any
|
||||
if err := json.Unmarshal(result, &arr); err != nil {
|
||||
return nil, "", fmt.Errorf("解析 list_files 返回失败: result 数组无效: %w", err)
|
||||
}
|
||||
parsed, err := toItems(arr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return parsed, "", nil
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("解析 list_files 返回失败: result 既不是 {items:[]} 也不是数组")
|
||||
}
|
||||
|
||||
// validateLocalDirAbs 校验 --local-folder 必须是绝对路径,返回其清理后的绝对路径。
|
||||
func validateLocalDirAbs(localDir string) (string, error) {
|
||||
if localDir == "" {
|
||||
return "", fmt.Errorf("flag --local-folder is required")
|
||||
}
|
||||
if !filepath.IsAbs(localDir) {
|
||||
return "", fmt.Errorf("--local-folder 必须是绝对路径: %s", localDir)
|
||||
}
|
||||
return filepath.Clean(localDir), nil
|
||||
}
|
||||
|
||||
// isSafeRemoteSegment 校验单个远端条目名称是否可安全用作一层相对路径段。
|
||||
// 拒绝空串、"."、".."、ASCII 控制字符、含卷标(如 Windows 的 "C:")以及任意平台
|
||||
// 的路径分隔符(/ 与 \)——这些成分无法安全表示为本地文件名,或可能让下载目标逃逸
|
||||
// 出本地根目录。
|
||||
func isSafeRemoteSegment(name string) bool {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] <= 0x1f || name[i] == 0x7f {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 任意平台分隔符都不允许出现在单层名称里。
|
||||
if strings.ContainsAny(name, `/\`) {
|
||||
return false
|
||||
}
|
||||
// 余下的卷标/规范化检查只在 Windows 上可达:Unix 的 filepath.VolumeName 恒为空,
|
||||
// 且经上面的分隔符过滤后 filepath.Clean 不会再改写名称。按平台分文件承载。
|
||||
return isSafeRemoteSegmentPlatform(name)
|
||||
}
|
||||
|
||||
// resolveLocalTarget 把远端 rel_path 拼到本地根目录 absDir 下,并确认结果仍位于
|
||||
// absDir 内。除词法检查(filepath.Rel)外,还解析已存在路径组件的符号链接,防止
|
||||
// root 内的目录软链(如 root/sub -> /outside)把落盘点指到根目录外。逃逸时返回错误。
|
||||
func resolveLocalTarget(absDir, rel string) (string, error) {
|
||||
target := filepath.Join(absDir, filepath.FromSlash(rel))
|
||||
back, err := filepath.Rel(absDir, target)
|
||||
if err != nil || back == ".." || strings.HasPrefix(back, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("远端路径 %q 逃逸出本地根目录", rel)
|
||||
}
|
||||
if err := verifyNoSymlinkEscape(absDir, target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// verifyNoSymlinkEscape 挡住「absDir 之内的目录软链指向外部」的逃逸:从 target 的父
|
||||
// 目录向上、在**不越过 absDir 边界**的前提下,找到最深的已存在祖先,解析其真实路径
|
||||
// (EvalSymlinks 展开整条软链),确认仍位于 absDir 真实路径之内。
|
||||
//
|
||||
// absDir 尚不存在时直接放行:其下不可能有已存在的组件可供逃逸,后续 MkdirAll 只会
|
||||
// 创建真实目录、绝不新建软链——这也是 pull 自动创建目标根目录的正常路径。target 尚不
|
||||
// 存在的末段同理无软链可谈,故只需校验 [absDir, target] 之间的现有组件。
|
||||
func verifyNoSymlinkEscape(absDir, target string) error {
|
||||
absDir = filepath.Clean(absDir)
|
||||
realRoot, err := filepath.EvalSymlinks(absDir)
|
||||
if err != nil {
|
||||
// 根目录尚不存在(无软链可解析)→ 放行,交给 MkdirAll 按需创建真实目录。
|
||||
return nil
|
||||
}
|
||||
|
||||
// 循环必然终止,无需不动点兜底:每轮 ancestor 严格变短,要么越界后由下面的 ".."
|
||||
// 分支退出,要么下降到 absDir 自身——absDir 必然存在(EvalSymlinks 刚成功),于是
|
||||
// 在 Lstat 成功分支退出。即便 absDir 在此期间被删除,下一轮 ancestor 升到 absDir
|
||||
// 之上,同样由 ".." 分支退出。
|
||||
ancestor := filepath.Dir(target)
|
||||
for {
|
||||
// 越过 absDir 边界(祖先跑到根目录之上)就停止:根目录之上不属于逃逸判定范围。
|
||||
rel, relErr := filepath.Rel(absDir, ancestor)
|
||||
if relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return nil
|
||||
}
|
||||
if _, statErr := os.Lstat(ancestor); statErr == nil {
|
||||
real, evErr := filepath.EvalSymlinks(ancestor)
|
||||
if evErr != nil {
|
||||
return fmt.Errorf("解析路径 %q 失败: %w", ancestor, evErr)
|
||||
}
|
||||
back, bErr := filepath.Rel(realRoot, real)
|
||||
if bErr != nil || back == ".." || strings.HasPrefix(back, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("路径 %q 经符号链接逃逸出本地根目录", target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ancestor = filepath.Dir(ancestor)
|
||||
}
|
||||
}
|
||||
|
||||
// md5File 计算文件内容的 MD5,并按 base64 编码返回(与钉盘 list_files 返回的
|
||||
// md5 字段编码一致,便于直接字符串比较)。变量形式只用于测试确定性注入读取失败;
|
||||
// 测试必须通过 testseam.Swap 替换并自动恢复。
|
||||
var md5File = func(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// statusRootLstat 是根类型校验的注入 seam;测试通过 testseam.Swap 注入不同的
|
||||
// FileInfo 来复现 Windows 上无法创建的符号链接场景。
|
||||
var statusRootLstat = os.Lstat
|
||||
|
||||
// statusWalkDir 是本地目录遍历的注入 seam。改造根类型校验后,filepath.WalkDir
|
||||
// 的错误分支(walkLocalTreeEntry 之外的外层错误)在 macOS/Windows 上都难以自然
|
||||
// 触发,需要 seam 让该分支可以确定性回归。
|
||||
var statusWalkDir = filepath.WalkDir
|
||||
|
||||
// walkLocalTree 递归遍历本地目录,只收集常规文件(跳过符号链接、设备文件等)。
|
||||
// rel_path 统一使用 / 作为分隔符,相对于 root。
|
||||
//
|
||||
// 这里只记录路径与 mtime,不计算 MD5:本地 hash 采用惰性策略,仅在文件双端
|
||||
// 都存在且非 quick 模式时,由 compareTrees 按需计算(见 judgeFileMatch)。
|
||||
func walkLocalTree(root string) (map[string]*localFile, error) {
|
||||
// filepath.WalkDir 不会跟随根本身的符号链接:若 root 是指向目录的 symlink,
|
||||
// WalkDir 只以 symlink 身份触发一次回调,随后 !IsRegular 分支静默跳过,最终
|
||||
// 返回空索引,让 status 把所有远端文件报成 new_remote。fail-closed 于遍历前:
|
||||
// 根必须是真实目录,任何其它类型都必须报错。
|
||||
info, err := statusRootLstat(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取本地根目录身份失败: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("本地根目录 %q 是符号链接,请传入真实目录路径", root)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("本地根目录 %q 不是目录", root)
|
||||
}
|
||||
files := make(map[string]*localFile)
|
||||
err = statusWalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
return walkLocalTreeEntry(root, path, d, err, files)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// runDriveStatusWalkLocalTree 仅作为 runDriveStatus 扫描失败分支的确定性测试 seam;
|
||||
// 测试必须通过 testseam.Swap 替换并自动恢复。
|
||||
var runDriveStatusWalkLocalTree = walkLocalTree
|
||||
|
||||
// walkLocalTreeEntry 是 walkLocalTree 的单条目处理逻辑。抽成命名函数只为可测:
|
||||
// Info() / filepath.Rel 的失败在真实 WalkDir 下几乎不可复现,单测可直接注入。
|
||||
func walkLocalTreeEntry(root, path string, d fs.DirEntry, err error, files map[string]*localFile) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 只比对常规文件;符号链接、设备文件等被忽略。
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
|
||||
files[relSlash] = &localFile{
|
||||
RelPath: relSlash,
|
||||
AbsPath: path,
|
||||
Size: info.Size(),
|
||||
ModTimeMillis: info.ModTime().UnixMilli(),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 双端都存在文件的比对结论。
|
||||
const (
|
||||
matchUnchanged = "unchanged"
|
||||
matchModified = "modified"
|
||||
matchUnknown = "unknown" // exact 模式且远端无可靠 md5,内容无法核对
|
||||
)
|
||||
|
||||
// judgeFileMatch 判定双端都存在的文件本次的比对结论:unchanged / modified / unknown。
|
||||
//
|
||||
// exact 模式下,本地 MD5 在此处按需计算——只有走到这里的文件才是双端都存在的,
|
||||
// local-only 文件不会触发 hash 计算。若远端未返回 md5(list_files 目前通常如此),
|
||||
// 无法核对内容,则返回 unknown:不判 unchanged(避免把不同内容误报为未变更),
|
||||
// 也不硬判 modified(避免把相同内容误报为已变更),让降级如实反映在输出中。
|
||||
func judgeFileMatch(lf *localFile, rf *remoteFile, quick bool) (string, error) {
|
||||
if quick {
|
||||
// quick:远端时间戳必须可信且与本地 mtime 相等才算 unchanged;
|
||||
// 远端时间缺失/非法时走保守路径记为 modified。
|
||||
if rf.ModifiedTimeValid && lf.ModTimeMillis == rf.ModifiedTime {
|
||||
return matchUnchanged, nil
|
||||
}
|
||||
return matchModified, nil
|
||||
}
|
||||
|
||||
// exact:远端缺少可靠 md5 时无法核对内容 → unknown(既不判 unchanged 也不判 modified)。
|
||||
if rf.Hash == "" {
|
||||
return matchUnknown, nil
|
||||
}
|
||||
|
||||
// exact:按需计算本地 MD5(base64),再与远端 md5(钉盘也返回 base64)直接比较。
|
||||
if lf.Hash == "" {
|
||||
h, err := md5File(lf.AbsPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("计算 %s 的 MD5 失败: %w", lf.RelPath, err)
|
||||
}
|
||||
lf.Hash = h
|
||||
}
|
||||
if lf.Hash == rf.Hash {
|
||||
return matchUnchanged, nil
|
||||
}
|
||||
return matchModified, nil
|
||||
}
|
||||
|
||||
// compareTrees 比较本地与云端文件树,产出五类差异。
|
||||
func compareTrees(local map[string]*localFile, remote map[string]*remoteFile, detection string, quick bool) (driveStatusResult, error) {
|
||||
res := driveStatusResult{
|
||||
Detection: detection,
|
||||
NewLocal: []driveStatusEntry{},
|
||||
NewRemote: []driveStatusEntry{},
|
||||
Modified: []driveStatusEntry{},
|
||||
Unchanged: []driveStatusEntry{},
|
||||
Unknown: []driveStatusEntry{},
|
||||
}
|
||||
|
||||
for rel, lf := range local {
|
||||
rf, ok := remote[rel]
|
||||
if !ok {
|
||||
res.NewLocal = append(res.NewLocal, driveStatusEntry{RelPath: rel})
|
||||
continue
|
||||
}
|
||||
verdict, err := judgeFileMatch(lf, rf, quick)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
switch verdict {
|
||||
case matchUnchanged:
|
||||
res.Unchanged = append(res.Unchanged, driveStatusEntry{RelPath: rel})
|
||||
case matchUnknown:
|
||||
res.Unknown = append(res.Unknown, driveStatusEntry{RelPath: rel})
|
||||
default:
|
||||
res.Modified = append(res.Modified, driveStatusEntry{RelPath: rel})
|
||||
}
|
||||
}
|
||||
for rel := range remote {
|
||||
if _, ok := local[rel]; !ok {
|
||||
res.NewRemote = append(res.NewRemote, driveStatusEntry{RelPath: rel})
|
||||
}
|
||||
}
|
||||
|
||||
sortEntries(res.NewLocal)
|
||||
sortEntries(res.NewRemote)
|
||||
sortEntries(res.Modified)
|
||||
sortEntries(res.Unchanged)
|
||||
sortEntries(res.Unknown)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func sortEntries(entries []driveStatusEntry) {
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].RelPath < entries[j].RelPath })
|
||||
}
|
||||
|
||||
func runDriveStatus(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil {
|
||||
return err
|
||||
}
|
||||
localDir := mustGetFlag(cmd, "local-folder")
|
||||
// remote-folder 必传:待比对的云端根文件夹 dentryUuid。
|
||||
remoteDirID := mustGetFlag(cmd, "remote-folder")
|
||||
// space-id 可选:不传则由 fetchRemoteDriveTree 使用「我的文件」对应的空间。
|
||||
spaceID := mustGetFlag(cmd, "space-id")
|
||||
|
||||
quick, _ := cmd.Flags().GetBool("quick")
|
||||
detection := "exact"
|
||||
if quick {
|
||||
detection = "quick"
|
||||
}
|
||||
|
||||
absDir, err := validateLocalDirAbs(localDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
local, err := runDriveStatusWalkLocalTree(absDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("扫描本地目录失败: %w", err)
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
remoteIndex, err := fetchRemoteDriveTree(ctx, spaceID, remoteDirID, quick)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := compareTrees(local, remoteIndex, detection, quick)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return deps.Out.PrintJSON(result)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// driveScriptCaller — 可编程 MCP mock:按 (toolName, 第几次调用) 决定返回,
|
||||
// 覆盖分页、递归、错误分支等 walkRemoteDir / runDriveStatus 路径。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
type driveScriptCaller struct {
|
||||
calls []driveMCPCall
|
||||
reply func(toolName string, args map[string]any, nth int) (string, error)
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (m *driveScriptCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
nth := 0
|
||||
for _, c := range m.calls {
|
||||
if c.toolName == toolName {
|
||||
nth++
|
||||
}
|
||||
}
|
||||
m.calls = append(m.calls, driveMCPCall{toolName: toolName, args: args})
|
||||
text, err := m.reply(toolName, args, nth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (m *driveScriptCaller) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return m.CallTool(ctx, productID, toolName, args)
|
||||
}
|
||||
|
||||
func (m *driveScriptCaller) Format() string { return "raw" }
|
||||
func (m *driveScriptCaller) DryRun() bool { return m.dryRun }
|
||||
func (m *driveScriptCaller) Fields() string { return "" }
|
||||
func (m *driveScriptCaller) JQ() string { return "" }
|
||||
|
||||
func (m *driveScriptCaller) callsFor(tool string) []driveMCPCall {
|
||||
var out []driveMCPCall
|
||||
for _, c := range m.calls {
|
||||
if c.toolName == tool {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var errTestList = errors.New("list_files boom")
|
||||
|
||||
// runDriveCmd 以给定 caller 执行 `dws drive <args...>`。
|
||||
//
|
||||
// pull / push / sync 声明了 Safety.Confirmation = user_required,非交互环境下必须带
|
||||
// --yes 才能越过统一确认门;这里自动补上,让用例专注被测行为。要断言「未确认即拒绝」
|
||||
// 请改用 runDriveCmdWithoutConfirm。
|
||||
func runDriveCmd(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
switch {
|
||||
case len(args) == 0:
|
||||
case args[0] == "pull", args[0] == "push", args[0] == "sync":
|
||||
args = append(args, "--yes")
|
||||
}
|
||||
return runDriveCmdWithoutConfirm(t, caller, args...)
|
||||
}
|
||||
|
||||
// runDriveCmdWithoutConfirm 原样执行,不补 --yes,用于验证确认门本身。
|
||||
func runDriveCmdWithoutConfirm(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.AddCommand(newDriveCommand())
|
||||
|
||||
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, args...))
|
||||
root.SetArgs(append([]string{"drive"}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// runDriveStatus — 端到端:本地 + 远端树 → diff
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 端到端 status:递归进入子文件夹、拼接 rel_path、跳过非 file 类型。
|
||||
func TestCrossPlatformCoverageDriveStatus_endToEndNestedTree(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "root.txt"), "r")
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "sub", "nested.txt"), "n")
|
||||
|
||||
caller := &driveScriptCaller{reply: func(_ string, args map[string]any, _ int) (string, error) {
|
||||
switch args["parentId"] {
|
||||
case "ROOT":
|
||||
return `{"result":{"items":[` +
|
||||
`{"name":"root.txt","type":"file","fileId":"R1","modifyTime":1000},` +
|
||||
`{"name":"sub","type":"folder","fileId":"SUB"},` +
|
||||
`{"name":"online.adoc","type":"document","fileId":"D1"}` +
|
||||
`],"nextToken":""}}`, nil
|
||||
case "SUB":
|
||||
return `{"result":{"items":[{"name":"nested.txt","type":"file","fileId":"N1","modifyTime":2000}],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}}
|
||||
|
||||
if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// 递归进入 sub 一次;不属于文件夹镜像范围的在线文档不产生额外调用。
|
||||
if got := len(caller.callsFor("list_files")); got != 2 {
|
||||
t.Errorf("expected 2 list_files calls (ROOT + SUB), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页 token 未推进意味着远端清单可能被截断,必须失败而不是把当前页当作 EOF。
|
||||
func TestCrossPlatformCoverageDriveStatus_stalledPaginationTokenFailsClosed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
caller := &driveScriptCaller{reply: func(_ string, args map[string]any, nth int) (string, error) {
|
||||
switch nth {
|
||||
case 0:
|
||||
if _, ok := args["nextToken"]; ok {
|
||||
t.Error("first page must not carry nextToken")
|
||||
}
|
||||
return `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":"T1"}}`, nil
|
||||
case 1:
|
||||
if args["nextToken"] != "T1" {
|
||||
t.Errorf("second page nextToken = %v, want T1", args["nextToken"])
|
||||
}
|
||||
// 返回同一个 token → 未推进,必须 fail closed。
|
||||
return `{"result":{"items":[{"name":"b.txt","type":"file","fileId":"B"}],"nextToken":"T1"}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}}
|
||||
|
||||
err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--quick")
|
||||
if err == nil || !strings.Contains(err.Error(), "分页 token") {
|
||||
t.Fatalf("error = %v, want stalled pagination failure", err)
|
||||
}
|
||||
if got := len(caller.callsFor("list_files")); got != 2 {
|
||||
t.Fatalf("stalled token must fail after 2 calls, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// space-id 显式传入时必须透传到 list_files。
|
||||
func TestCrossPlatformCoverageDriveStatus_passesSpaceID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) {
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}}
|
||||
if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT", "--space-id", "SP1"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := caller.callsFor("list_files")[0].args["spaceId"]; got != "SP1" {
|
||||
t.Errorf("spaceId = %v, want SP1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// list_files 失败 → status 直接报错。
|
||||
func TestCrossPlatformCoverageDriveStatus_listFailurePropagates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }}
|
||||
if err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected list_files error to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// 返回体无法解析 → status 报解析失败。
|
||||
func TestCrossPlatformCoverageDriveStatus_unparsableListPropagates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
||||
return `{"result":"not-a-list"}`, nil
|
||||
}}
|
||||
err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT")
|
||||
if err == nil || !strings.Contains(err.Error(), "list_files") {
|
||||
t.Fatalf("expected list_files parse error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// exact 模式下本地文件不可读 → MD5 计算失败要作为错误上抛,不能静默当成一致。
|
||||
func TestCrossPlatformCoverageDriveStatus_unreadableLocalFileFailsExactCompare(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secret.txt")
|
||||
mustWrite(t, p, "data")
|
||||
testseam.Swap(t, &md5File, func(string) (string, error) {
|
||||
return "", errors.New("MD5 boom")
|
||||
})
|
||||
|
||||
caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) {
|
||||
return `{"result":{"items":[{"name":"secret.txt","type":"file","fileId":"S","md5":"abc"}],"nextToken":""}}`, nil
|
||||
}}
|
||||
err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT")
|
||||
if err == nil || !strings.Contains(err.Error(), "MD5") {
|
||||
t.Fatalf("expected MD5 failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveStatus_walkLocalFailurePropagates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testseam.Swap(t, &runDriveStatusWalkLocalTree, func(string) (map[string]*localFile, error) {
|
||||
return nil, errors.New("walk boom")
|
||||
})
|
||||
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
||||
t.Error("local scan failure must abort before MCP calls")
|
||||
return "", nil
|
||||
}}
|
||||
err := runDriveCmd(t, caller, "status", "--local-folder", dir, "--remote-folder", "ROOT")
|
||||
if err == nil || !strings.Contains(err.Error(), "扫描本地目录失败") {
|
||||
t.Fatalf("expected local scan failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --local-folder 不是绝对路径 → 校验拦下,不发起任何 MCP 调用。
|
||||
func TestCrossPlatformCoverageDriveStatus_relativeLocalFolderRejected(t *testing.T) {
|
||||
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
||||
t.Error("must not call MCP when --local-folder is rejected")
|
||||
return "", nil
|
||||
}}
|
||||
if err := runDriveCmd(t, caller, "status", "--local-folder", "relative/path", "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected relative --local-folder to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// walkRemoteDir — 深度上限
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 远端目录自引用时深度上限必须中止遍历,避免无限递归。
|
||||
func TestCrossPlatformCoverageWalkRemoteDir_depthLimitAborts(t *testing.T) {
|
||||
caller := &driveScriptCaller{reply: func(_ string, _ map[string]any, _ int) (string, error) {
|
||||
// 每层都返回一个同名子文件夹 → 无限深。
|
||||
return `{"result":{"items":[{"name":"loop","type":"folder","fileId":"LOOP"}],"nextToken":""}}`, nil
|
||||
}}
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"}) // 工具路由需要命令上下文
|
||||
|
||||
_, err := fetchRemoteDriveTree(context.Background(), "", "ROOT", true)
|
||||
if err == nil || !strings.Contains(err.Error(), "循环引用") {
|
||||
t.Fatalf("expected depth-limit abort, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// isSafeRemoteSegment / resolveLocalTarget 的剩余分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageIsSafeRemoteSegment_volumeAndNonCanonical(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
if isSafeRemoteSegment(`C:name.txt`) {
|
||||
t.Error("volume-qualified name must be rejected")
|
||||
}
|
||||
}
|
||||
// 非规范形式(Clean 后不等于自身)必须拒绝。
|
||||
for _, name := range []string{"./a.txt", "a/../b.txt", "a//b.txt"} {
|
||||
if isSafeRemoteSegment(name) {
|
||||
t.Errorf("non-canonical %q must be rejected", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// md5File / walkLocalTree 错误分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageMD5File_missingFile(t *testing.T) {
|
||||
if _, err := md5File(filepath.Join(t.TempDir(), "nope.bin")); err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMD5File_directoryIsNotReadable(t *testing.T) {
|
||||
// 目录不是常规文件:io.Copy 读取时必然报错。
|
||||
if _, err := md5File(t.TempDir()); err == nil {
|
||||
t.Fatal("expected error when hashing a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWalkLocalTree_missingRootErrors(t *testing.T) {
|
||||
if _, err := walkLocalTree(filepath.Join(t.TempDir(), "absent")); err == nil {
|
||||
t.Fatal("expected error for missing local root")
|
||||
}
|
||||
}
|
||||
|
||||
// 根路径是指向目录的符号链接时,filepath.WalkDir 只把根本身当成一次 !IsRegular
|
||||
// 回调静默跳过,结果是空索引,而 status 会把所有远端文件误报为 new_remote。
|
||||
// 必须在遍历前 fail-closed。这里通过 seam 注入,兼顾无法真实创建 symlink 的平台。
|
||||
func TestCrossPlatformCoverageWalkLocalTree_rejectsSymlinkRootViaSeam(t *testing.T) {
|
||||
testseam.Swap(t, &statusRootLstat, func(string) (os.FileInfo, error) {
|
||||
return fakeSymlinkFileInfo{}, nil
|
||||
})
|
||||
if _, err := walkLocalTree(t.TempDir()); err == nil || !strings.Contains(err.Error(), "符号链接") {
|
||||
t.Fatalf("symlink root must fail closed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 真实 symlink 复现:非 Windows 平台可以直接创建,进一步保证 seam 与真实语义一致。
|
||||
func TestCrossPlatformCoverageWalkLocalTree_rejectsSymlinkRootReal(t *testing.T) {
|
||||
target := t.TempDir()
|
||||
link := filepath.Join(t.TempDir(), "root-link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
if _, err := walkLocalTree(link); err == nil || !strings.Contains(err.Error(), "符号链接") {
|
||||
t.Fatalf("symlink root must fail closed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 根类型校验通过后 WalkDir 内部返回的错误必须原样上抛,不能被吞掉;根消失或
|
||||
// 权限变更之类的情形在 Linux/Windows runner 上都难以稳定复现,用 seam 注入。
|
||||
func TestCrossPlatformCoverageWalkLocalTree_propagatesWalkDirError(t *testing.T) {
|
||||
testseam.Swap(t, &statusWalkDir, func(string, fs.WalkDirFunc) error {
|
||||
return errWalkDirSentinel
|
||||
})
|
||||
dir := t.TempDir()
|
||||
if _, err := walkLocalTree(dir); !errors.Is(err, errWalkDirSentinel) {
|
||||
t.Fatalf("walk dir error must be surfaced, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var errWalkDirSentinel = errors.New("simulated walk dir failure")
|
||||
|
||||
// 根路径既非目录也非符号链接(例如普通文件被误传)时,也必须报错而非空索引。
|
||||
func TestCrossPlatformCoverageWalkLocalTree_rejectsNonDirectoryRoot(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "not-a-dir")
|
||||
mustWrite(t, file, "x")
|
||||
if _, err := walkLocalTree(file); err == nil || !strings.Contains(err.Error(), "不是目录") {
|
||||
t.Fatalf("file root must fail closed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSymlinkFileInfo 是一个 Mode()&os.ModeSymlink != 0 的 os.FileInfo。Windows
|
||||
// 上创建目录符号链接需要管理员权限,测试通过它绕过创建步骤。
|
||||
type fakeSymlinkFileInfo struct{}
|
||||
|
||||
func (fakeSymlinkFileInfo) Name() string { return "root-link" }
|
||||
func (fakeSymlinkFileInfo) Size() int64 { return 0 }
|
||||
func (fakeSymlinkFileInfo) Mode() os.FileMode { return os.ModeSymlink | 0o755 }
|
||||
func (fakeSymlinkFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (fakeSymlinkFileInfo) IsDir() bool { return false }
|
||||
func (fakeSymlinkFileInfo) Sys() any { return nil }
|
||||
|
||||
// 非常规文件(FIFO/符号链接目标缺失等)不计入本地索引。
|
||||
func TestCrossPlatformCoverageWalkLocalTree_skipsIrregularEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "real.txt"), "x")
|
||||
if err := os.Symlink(filepath.Join(dir, "absent"), filepath.Join(dir, "dangling")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
got, err := walkLocalTree(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := got["dangling"]; ok {
|
||||
t.Error("dangling symlink must not be indexed")
|
||||
}
|
||||
if _, ok := got["real.txt"]; !ok {
|
||||
t.Error("regular file must be indexed")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// toMillis 的 json.Number / float64 分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDriveItem_modifiedMillisFromNumericForms(t *testing.T) {
|
||||
if got, ok := (driveItem{"modifyTime": float64(1500)}).modifiedMillis(); !ok || got != 1500 {
|
||||
t.Errorf("float64 modifyTime = (%d,%v), want (1500,true)", got, ok)
|
||||
}
|
||||
if got, ok := (driveItem{"modifyTime": json.Number("2500")}).modifiedMillis(); !ok || got != 2500 {
|
||||
t.Errorf("json.Number modifyTime = (%d,%v), want (2500,true)", got, ok)
|
||||
}
|
||||
if _, ok := (driveItem{"modifyTime": json.Number("nope")}).modifiedMillis(); ok {
|
||||
t.Error("malformed json.Number modifyTime must be invalid")
|
||||
}
|
||||
if _, ok := (driveItem{"modifyTime": float64(0)}).modifiedMillis(); ok {
|
||||
t.Error("non-positive modifyTime must be invalid")
|
||||
}
|
||||
if _, ok := (driveItem{}).modifiedMillis(); ok {
|
||||
t.Error("missing modifyTime must be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// fileSize 的 float64 / json.Number 两种形态,以及缺失时的兜底。
|
||||
func TestCrossPlatformCoverageDriveItem_sizeForms(t *testing.T) {
|
||||
if got := (driveItem{"fileSize": float64(42)}).size(); got != 42 {
|
||||
t.Errorf("float fileSize = %d, want 42", got)
|
||||
}
|
||||
if got := (driveItem{"fileSize": json.Number("77")}).size(); got != 77 {
|
||||
t.Errorf("json.Number fileSize = %d, want 77", got)
|
||||
}
|
||||
if got := (driveItem{"fileSize": json.Number("not-a-number")}).size(); got != 0 {
|
||||
t.Errorf("malformed json.Number fileSize = %d, want 0", got)
|
||||
}
|
||||
if got := (driveItem{}).size(); got != 0 {
|
||||
t.Errorf("missing fileSize = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// validateLocalDirAbs — 本地路径校验(必须为绝对路径)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageValidateLocalDirAbs_empty(t *testing.T) {
|
||||
if _, err := validateLocalDirAbs(""); err == nil {
|
||||
t.Fatal("expected error for empty --local-folder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateLocalDirAbs_relative(t *testing.T) {
|
||||
if _, err := validateLocalDirAbs(filepath.Join(".", "repo")); err == nil {
|
||||
t.Fatal("expected error for relative path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateLocalDirAbs_ok(t *testing.T) {
|
||||
abs := filepath.Join(t.TempDir(), "repo")
|
||||
got, err := validateLocalDirAbs(abs)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != filepath.Clean(abs) {
|
||||
t.Errorf("got %q, want %q", got, filepath.Clean(abs))
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// walkLocalTree — 本地遍历
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageWalkLocalTree_collectsRegularFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "a.txt"), "hello")
|
||||
mustWrite(t, filepath.Join(root, "sub", "b.txt"), "world")
|
||||
|
||||
files, err := walkLocalTree(root)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 files, got %d: %v", len(files), files)
|
||||
}
|
||||
// rel_path 用 / 分隔(跨平台一致)
|
||||
if _, ok := files["sub/b.txt"]; !ok {
|
||||
t.Errorf("expected key sub/b.txt, got keys %v", keys(files))
|
||||
}
|
||||
// walk 阶段不计算 hash(惰性策略),但记录 AbsPath 与 mtime
|
||||
if files["a.txt"].Hash != "" {
|
||||
t.Error("walkLocalTree should not compute MD5 eagerly")
|
||||
}
|
||||
if files["a.txt"].AbsPath == "" {
|
||||
t.Error("walkLocalTree should record AbsPath")
|
||||
}
|
||||
if files["a.txt"].ModTimeMillis == 0 {
|
||||
t.Error("walkLocalTree should record mtime")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// compareTrees — 四类差异分类
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageCompareTrees_exact(t *testing.T) {
|
||||
local := map[string]*localFile{
|
||||
"same.txt": {RelPath: "same.txt", Hash: "h1"},
|
||||
"diff.txt": {RelPath: "diff.txt", Hash: "local"},
|
||||
"onlyme.txt": {RelPath: "onlyme.txt", Hash: "h3"},
|
||||
}
|
||||
remote := map[string]*remoteFile{
|
||||
"same.txt": {RelPath: "same.txt", Hash: "h1"},
|
||||
"diff.txt": {RelPath: "diff.txt", Hash: "remote"},
|
||||
"onlythem.txt": {RelPath: "onlythem.txt", Hash: "h4"},
|
||||
}
|
||||
res, err := compareTrees(local, remote, "exact", false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
assertPaths(t, "unchanged", res.Unchanged, "same.txt")
|
||||
assertPaths(t, "modified", res.Modified, "diff.txt")
|
||||
assertPaths(t, "new_local", res.NewLocal, "onlyme.txt")
|
||||
assertPaths(t, "new_remote", res.NewRemote, "onlythem.txt")
|
||||
if res.Detection != "exact" {
|
||||
t.Errorf("detection = %q, want exact", res.Detection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareTrees_exact_unknownRemoteHashIsUnknown(t *testing.T) {
|
||||
local := map[string]*localFile{"a.txt": {RelPath: "a.txt", Hash: "h1"}}
|
||||
remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", Hash: ""}}
|
||||
res, err := compareTrees(local, remote, "exact", false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// 远端无 md5 → 无法核对内容,归入 unknown(既非 unchanged 也非 modified)。
|
||||
assertPaths(t, "unknown", res.Unknown, "a.txt")
|
||||
if len(res.Unchanged) != 0 {
|
||||
t.Error("unknown remote hash must not be judged unchanged")
|
||||
}
|
||||
if len(res.Modified) != 0 {
|
||||
t.Error("unknown remote hash must not be judged modified")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompareTrees_exact_hashesOnlyIntersection 验证本地 hash 只在双端都存在时才
|
||||
// 计算:local-only 文件即使 AbsPath 指向不存在的文件也不会触发 hash(不报错)。
|
||||
func TestCrossPlatformCoverageCompareTrees_exact_hashesOnlyIntersection(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bothPath := filepath.Join(root, "both.txt")
|
||||
mustWrite(t, bothPath, "same-content")
|
||||
h, err := md5File(bothPath)
|
||||
if err != nil {
|
||||
t.Fatalf("md5File: %v", err)
|
||||
}
|
||||
|
||||
local := map[string]*localFile{
|
||||
"both.txt": {RelPath: "both.txt", AbsPath: bothPath},
|
||||
"local.txt": {RelPath: "local.txt", AbsPath: filepath.Join(root, "does-not-exist.txt")},
|
||||
}
|
||||
remote := map[string]*remoteFile{
|
||||
"both.txt": {RelPath: "both.txt", Hash: h},
|
||||
}
|
||||
res, err := compareTrees(local, remote, "exact", false)
|
||||
if err != nil {
|
||||
t.Fatalf("local-only file must not be hashed, got error: %v", err)
|
||||
}
|
||||
assertPaths(t, "unchanged", res.Unchanged, "both.txt")
|
||||
assertPaths(t, "new_local", res.NewLocal, "local.txt")
|
||||
// 双端都存在的 both.txt 被惰性 hash 后应回填 Hash
|
||||
if local["both.txt"].Hash == "" {
|
||||
t.Error("intersection file should have been hashed lazily")
|
||||
}
|
||||
if local["local.txt"].Hash != "" {
|
||||
t.Error("local-only file should never be hashed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompareTrees_exact_noRemoteMD5AllUnknown 验证 exact 模式在远端无 md5 时
|
||||
// 一律归入 unknown——不再用 fileSize + modified_time 近似判 unchanged(否则大小与
|
||||
// mtime 恰好相同但内容不同的文件会被误报为未变更),无论大小/时间是否相同。
|
||||
func TestCrossPlatformCoverageCompareTrees_exact_noRemoteMD5AllUnknown(t *testing.T) {
|
||||
local := map[string]*localFile{
|
||||
"sameSizeTime.txt": {RelPath: "sameSizeTime.txt", Size: 100, ModTimeMillis: 1000},
|
||||
"diffsize.txt": {RelPath: "diffsize.txt", Size: 100, ModTimeMillis: 1000},
|
||||
"difftime.txt": {RelPath: "difftime.txt", Size: 100, ModTimeMillis: 1000},
|
||||
}
|
||||
remote := map[string]*remoteFile{
|
||||
// 远端均无 md5(Hash 空)→ 全部 unknown,绝不判 unchanged。
|
||||
"sameSizeTime.txt": {RelPath: "sameSizeTime.txt", Size: 100, ModifiedTime: 1000, ModifiedTimeValid: true},
|
||||
"diffsize.txt": {RelPath: "diffsize.txt", Size: 200, ModifiedTime: 1000, ModifiedTimeValid: true},
|
||||
"difftime.txt": {RelPath: "difftime.txt", Size: 100, ModifiedTime: 2000, ModifiedTimeValid: true},
|
||||
}
|
||||
res, err := compareTrees(local, remote, "exact", false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertPaths(t, "unknown", res.Unknown, "diffsize.txt", "difftime.txt", "sameSizeTime.txt")
|
||||
if len(res.Unchanged) != 0 {
|
||||
t.Errorf("远端无 md5 时不应有 unchanged, got %v", entryPaths(res.Unchanged))
|
||||
}
|
||||
if len(res.Modified) != 0 {
|
||||
t.Errorf("远端无 md5 时不应有 modified, got %v", entryPaths(res.Modified))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompareTrees_exact_base64RemoteMD5 验证 exact 用 base64 md5 比对:本地按
|
||||
// base64 算 md5,与钉盘返回的 base64 md5 直接相等 → unchanged。
|
||||
func TestCrossPlatformCoverageCompareTrees_exact_base64RemoteMD5(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "a.txt")
|
||||
mustWrite(t, p, "hello root\n") // 11 字节,md5 base64 = 6y9K7B0b2K0AInNZZV/70A==
|
||||
remoteB64 := "6y9K7B0b2K0AInNZZV/70A=="
|
||||
local := map[string]*localFile{"a.txt": {RelPath: "a.txt", AbsPath: p}}
|
||||
remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", Hash: remoteB64}}
|
||||
res, err := compareTrees(local, remote, "exact", false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertPaths(t, "unchanged", res.Unchanged, "a.txt")
|
||||
if len(res.Modified) != 0 {
|
||||
t.Errorf("同一 md5(base64) 不应判 modified, got modified=%d", len(res.Modified))
|
||||
}
|
||||
// 本地按需 hash 后应回填为 base64
|
||||
if local["a.txt"].Hash != remoteB64 {
|
||||
t.Errorf("本地 md5 应回填为 base64 %q, got %q", remoteB64, local["a.txt"].Hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareTrees_quick(t *testing.T) {
|
||||
local := map[string]*localFile{
|
||||
"eq.txt": {RelPath: "eq.txt", ModTimeMillis: 1000},
|
||||
"ne.txt": {RelPath: "ne.txt", ModTimeMillis: 1000},
|
||||
"bad.txt": {RelPath: "bad.txt", ModTimeMillis: 1000},
|
||||
}
|
||||
remote := map[string]*remoteFile{
|
||||
"eq.txt": {RelPath: "eq.txt", ModifiedTime: 1000, ModifiedTimeValid: true},
|
||||
"ne.txt": {RelPath: "ne.txt", ModifiedTime: 2000, ModifiedTimeValid: true},
|
||||
"bad.txt": {RelPath: "bad.txt", ModifiedTime: 1000, ModifiedTimeValid: false},
|
||||
}
|
||||
res, err := compareTrees(local, remote, "quick", true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
assertPaths(t, "unchanged", res.Unchanged, "eq.txt")
|
||||
// ne.txt 时间不等、bad.txt 远端时间不可信 → 都记为 modified
|
||||
assertPaths(t, "modified", res.Modified, "bad.txt", "ne.txt")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareTrees_emptyRemote_allNewLocal(t *testing.T) {
|
||||
local := map[string]*localFile{
|
||||
"a.txt": {RelPath: "a.txt", Hash: "h1"},
|
||||
"b.txt": {RelPath: "b.txt", Hash: "h2"},
|
||||
}
|
||||
res, err := compareTrees(local, map[string]*remoteFile{}, "exact", false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
assertPaths(t, "new_local", res.NewLocal, "a.txt", "b.txt")
|
||||
if len(res.NewRemote) != 0 || len(res.Modified) != 0 || len(res.Unchanged) != 0 {
|
||||
t.Error("with empty remote, only new_local should be populated")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// helpers
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func mustWrite(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string]*localFile) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertPaths(t *testing.T, label string, entries []driveStatusEntry, want ...string) {
|
||||
t.Helper()
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("%s: got %d entries %v, want %v", label, len(entries), entryPaths(entries), want)
|
||||
}
|
||||
for i, w := range want {
|
||||
if entries[i].RelPath != w {
|
||||
t.Errorf("%s[%d] = %q, want %q", label, i, entries[i].RelPath, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func entryPaths(entries []driveStatusEntry) []string {
|
||||
out := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
out[i] = e.RelPath
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// parseDriveList — list_files 返回解析
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageParseDriveList_resultItems(t *testing.T) {
|
||||
text := `{"result":{"items":[{"name":"a.txt","type":"file"},{"name":"sub","type":"folder"}],"nextToken":"tok"}}`
|
||||
items, token, err := parseDriveList(text)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
if token != "tok" {
|
||||
t.Errorf("token = %q, want tok", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseDriveList_topLevelArray(t *testing.T) {
|
||||
text := `{"result":[{"name":"a.txt","type":"file"}]}`
|
||||
items, token, err := parseDriveList(text)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].name() != "a.txt" {
|
||||
t.Errorf("unexpected items: %v", items)
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("expected empty token, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseDriveList_invalid(t *testing.T) {
|
||||
if _, _, err := parseDriveList("not json"); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// driveItem — 字段访问器(带 fallback)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDriveItem_accessors(t *testing.T) {
|
||||
it := driveItem{
|
||||
"name": "报告.pdf",
|
||||
"type": "file",
|
||||
"fileId": "d-123",
|
||||
"md5": "abc",
|
||||
"path": "/dir/报告.pdf",
|
||||
"modifyTime": float64(1700000000000),
|
||||
}
|
||||
if it.name() != "报告.pdf" {
|
||||
t.Errorf("name() = %q", it.name())
|
||||
}
|
||||
if it.id() != "d-123" {
|
||||
t.Errorf("id() = %q", it.id())
|
||||
}
|
||||
if it.typ() != "file" {
|
||||
t.Errorf("typ() = %q, want file", it.typ())
|
||||
}
|
||||
if it.hash() != "abc" {
|
||||
t.Errorf("hash() = %q", it.hash())
|
||||
}
|
||||
if it.path() != "/dir/报告.pdf" {
|
||||
t.Errorf("path() = %q", it.path())
|
||||
}
|
||||
if it.isFolder() {
|
||||
t.Error("type=file should not be a folder")
|
||||
}
|
||||
if !it.isFile() {
|
||||
t.Error("type=file should be a file")
|
||||
}
|
||||
ms, ok := it.modifiedMillis()
|
||||
if !ok || ms != 1700000000000 {
|
||||
t.Errorf("modifiedMillis() = %d,%v", ms, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveItem_folderAndFile(t *testing.T) {
|
||||
folder := driveItem{"name": "sub", "type": "folder"}
|
||||
if !folder.isFolder() || folder.isFile() {
|
||||
t.Error("type=folder should be folder, not file")
|
||||
}
|
||||
file := driveItem{"name": "a.txt", "type": "file"}
|
||||
if !file.isFile() || file.isFolder() {
|
||||
t.Error("type=file should be file, not folder")
|
||||
}
|
||||
// 非 file / 非 folder 的类型(在线文档、快捷方式等)都不纳入比对。
|
||||
doc := driveItem{"name": "设计稿", "type": "docx"}
|
||||
if doc.isFile() {
|
||||
t.Error("docx should not be treated as file")
|
||||
}
|
||||
shortcut := driveItem{"name": "快捷方式", "type": "shortcut"}
|
||||
if shortcut.isFile() {
|
||||
t.Error("shortcut should not be treated as file")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// toMillis — 时间字段归一化
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageToMillis(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{"epoch-ms-number", float64(1700000000000), 1700000000000, true},
|
||||
{"epoch-ms-string", "1700000000000", 1700000000000, true},
|
||||
{"rfc3339", "2023-11-14T22:13:20Z", 1700000000000, true},
|
||||
{"zero", float64(0), 0, false},
|
||||
{"empty", "", 0, false},
|
||||
{"garbage", "not-a-time", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok := toMillis(c.in)
|
||||
if ok != c.ok || (ok && got != c.want) {
|
||||
t.Errorf("toMillis(%v) = %d,%v; want %d,%v", c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
// isSafeRemoteSegmentPlatform 在非 Windows 平台没有额外约束:filepath.VolumeName
|
||||
// 恒为空字符串,而调用方已过滤掉 ""、"."、".." 与任意平台分隔符,剩下的名称
|
||||
// filepath.Clean 不会再改写。
|
||||
func isSafeRemoteSegmentPlatform(string) bool { return true }
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:build windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isSafeRemoteSegmentPlatform 承载 Windows 专有的单层名称校验。
|
||||
//
|
||||
// 除盘符与 Clean 改写外,还拒绝 Win32 非法字符、尾随点/空格以及设备保留名。
|
||||
// 这些名称即使不造成词法逃逸,也可能无法落盘、落到 ADS,或与另一个名称映射到
|
||||
// 同一 Win32 对象;文件夹镜像必须在构建权威远端树时统一 fail closed。
|
||||
func isSafeRemoteSegmentPlatform(name string) bool {
|
||||
if filepath.VolumeName(name) != "" {
|
||||
return false
|
||||
}
|
||||
if filepath.Clean(name) != name {
|
||||
return false
|
||||
}
|
||||
if strings.TrimRight(name, " .") != name {
|
||||
return false
|
||||
}
|
||||
for _, char := range name {
|
||||
if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"|?*`, char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if isWindowsReservedMirrorName(name) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isWindowsReservedMirrorName(name string) bool {
|
||||
base := name
|
||||
if index := strings.IndexAny(base, ".:"); index >= 0 {
|
||||
base = base[:index]
|
||||
}
|
||||
base = strings.ToUpper(strings.TrimRight(base, " ."))
|
||||
switch base {
|
||||
case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$":
|
||||
return true
|
||||
}
|
||||
if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9' {
|
||||
return true
|
||||
}
|
||||
runes := []rune(base)
|
||||
return len(runes) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) &&
|
||||
strings.ContainsRune("¹²³", runes[3])
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// keep-both 的本地保留副本通过同一个固定父目录 os.Root 原子建立硬链接;以下 seam
|
||||
// 仅用于文件系统失败与并发替换的确定性测试,测试必须通过 testseam.Swap 替换。
|
||||
var (
|
||||
syncRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
|
||||
syncPullOneFilePinned = pullOneFilePinned
|
||||
)
|
||||
|
||||
// ==========================================================
|
||||
// drive sync — 本地与钉盘双向同步(本地 ⇄ Drive)
|
||||
// ==========================================================
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws drive sync — 把 --local-folder 与 --remote-folder 做文件级双向同步。
|
||||
//
|
||||
// 复用 status 的差异判定(exact MD5 / --quick modified_time)先算出 diff,再按方向执行:
|
||||
// new_local 仅本地存在 → push(缺失的远端目录按需创建,再上传)
|
||||
// new_remote 仅钉盘存在 → pull(下载到本地对应路径)
|
||||
// modified 两侧都变更 → 按 --on-conflict 解决:
|
||||
// skip 默认:两侧都不动并保留两边内容
|
||||
// remote-wins :拉取远端覆盖本地
|
||||
// local-wins :上传本地覆盖远端
|
||||
// keep-both :本地改名保留,再拉取远端到原路径
|
||||
// ask :交互式逐个询问
|
||||
// unknown exact 模式远端无可靠 md5、内容无法核对 → 跳过(记 skipped,提示改用 --quick)
|
||||
// unchanged 两侧一致 → 不动
|
||||
//
|
||||
// 文件级同步——只新增/覆盖,不删除任何一侧的多余文件。summary.failed > 0 时以非零
|
||||
// 退出码退出,结构化 summary + diff + items 仍打印在 stdout 上。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// --on-conflict 的五种冲突解决策略。
|
||||
const (
|
||||
syncConflictLocalWins = "local-wins" // 上传本地覆盖远端
|
||||
syncConflictRemoteWins = "remote-wins" // 拉取远端覆盖本地
|
||||
syncConflictKeepBoth = "keep-both" // 本地改名保留,再拉取远端到原路径
|
||||
syncConflictAsk = "ask" // 交互式逐个询问
|
||||
syncConflictSkip = "skip" // 默认:两侧都变更时什么都不做,两边内容都保留
|
||||
)
|
||||
|
||||
// sync 动作分类(direction 标注每条记录属于哪个方向)。
|
||||
const (
|
||||
syncDirectionPull = "pull"
|
||||
syncDirectionPush = "push"
|
||||
syncDirectionConflict = "conflict"
|
||||
|
||||
syncActionDownloaded = "downloaded"
|
||||
syncActionUploaded = "uploaded"
|
||||
syncActionOverwritten = "overwritten"
|
||||
syncActionFolderCreated = "folder_created"
|
||||
syncActionRenamedLocal = "renamed_local"
|
||||
syncActionSkipped = "skipped"
|
||||
syncActionFailed = "failed"
|
||||
)
|
||||
|
||||
// driveSyncItem 是输出 items[] 中每条操作的明细。
|
||||
type driveSyncItem struct {
|
||||
RelPath string `json:"rel_path"`
|
||||
Action string `json:"action"`
|
||||
Direction string `json:"direction,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// driveSyncSummary 是各方向的计数汇总。
|
||||
type driveSyncSummary struct {
|
||||
Pulled int `json:"pulled"`
|
||||
Pushed int `json:"pushed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。
|
||||
type driveSyncDiff struct {
|
||||
NewLocal []driveStatusEntry `json:"new_local"`
|
||||
NewRemote []driveStatusEntry `json:"new_remote"`
|
||||
Modified []driveStatusEntry `json:"modified"`
|
||||
Unchanged []driveStatusEntry `json:"unchanged"`
|
||||
Unknown []driveStatusEntry `json:"unknown"`
|
||||
}
|
||||
|
||||
// driveSyncResult 是 sync 命令的输出 schema。
|
||||
type driveSyncResult struct {
|
||||
Detection string `json:"detection"`
|
||||
Diff driveSyncDiff `json:"diff"`
|
||||
Summary driveSyncSummary `json:"summary"`
|
||||
Items []driveSyncItem `json:"items"`
|
||||
}
|
||||
|
||||
type driveSyncDryRunResult struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Executed bool `json:"executed"`
|
||||
PreviewKind string `json:"preview_kind"`
|
||||
Operation string `json:"operation"`
|
||||
Plan driveSyncResult `json:"plan"`
|
||||
}
|
||||
|
||||
// driveSyncFailure 在 summary.failed > 0 时返回:结构化结果已打印到 stdout,
|
||||
// 这里只负责以 exit=1 退出并向 stderr 输出一行简短说明(与 drivePushFailure 一致)。
|
||||
type driveSyncFailure struct{ failed int }
|
||||
|
||||
func (e *driveSyncFailure) Error() string {
|
||||
return fmt.Sprintf("drive sync: %d item(s) failed", e.failed)
|
||||
}
|
||||
func (e *driveSyncFailure) RawStderr() string { return e.Error() }
|
||||
func (e *driveSyncFailure) ExitCode() int { return 1 }
|
||||
|
||||
func runDriveSync(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "local-folder", "remote-folder"); err != nil {
|
||||
return err
|
||||
}
|
||||
localDir := mustGetFlag(cmd, "local-folder")
|
||||
remoteDirID := mustGetFlag(cmd, "remote-folder")
|
||||
// space-id 可选:不传则由底层使用「我的文件」对应的空间。
|
||||
spaceID := mustGetFlag(cmd, "space-id")
|
||||
|
||||
onConflict, _ := cmd.Flags().GetString("on-conflict")
|
||||
if onConflict == "" {
|
||||
// 安全默认:两侧都变更时不擅自覆盖任何一侧。
|
||||
onConflict = syncConflictSkip
|
||||
}
|
||||
switch onConflict {
|
||||
case syncConflictSkip, syncConflictLocalWins, syncConflictRemoteWins, syncConflictKeepBoth, syncConflictAsk:
|
||||
default:
|
||||
return fmt.Errorf("--on-conflict 取值非法: %s(可选 skip|local-wins|remote-wins|keep-both|ask)", onConflict)
|
||||
}
|
||||
|
||||
quick, _ := cmd.Flags().GetBool("quick")
|
||||
detection := "exact"
|
||||
if quick {
|
||||
detection = "quick"
|
||||
}
|
||||
|
||||
absDir, err := validateLocalDirAbs(localDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pinnedRoot, err := openExistingPinnedPullRoot(absDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("扫描本地目录失败: %w", err)
|
||||
}
|
||||
defer pinnedRoot.close()
|
||||
// sync 的本地快照与后续 hash / upload / pull 全部绑定到同一个固定根。
|
||||
localDirs, localFiles, err := runDriveMirrorWalkLocalForPushPinned(pinnedRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("扫描本地目录失败: %w", err)
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
|
||||
// 远端现状:文件(含 hash/mtime/fileId)与目录(rel_path → fileId,"" 即根本身)。
|
||||
remoteFiles, remoteFolders, err := fetchRemoteTreeForPush(ctx, spaceID, remoteDirID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localByRel := make(map[string]localPushFile, len(localFiles))
|
||||
for _, f := range localFiles {
|
||||
localByRel[f.RelPath] = f
|
||||
}
|
||||
preflight := buildMirrorPreflight(localDirs, localFiles, remoteFiles, remoteFolders)
|
||||
addPinnedLocalTargetPreflightFailures(pinnedRoot, remoteFiles, preflight)
|
||||
|
||||
// 在计算 diff 前先识别文件/目录同路径冲突。冲突根及其全部后代都不能再进入
|
||||
// new_local / new_remote 或后续执行阶段,否则会在远端制造同名异类对象,或尝试
|
||||
// 把远端目录内容下载到本地文件之下。
|
||||
typeConflicts := make(map[string]string)
|
||||
for rel, msg := range preflight {
|
||||
typeConflicts[rel] = msg
|
||||
}
|
||||
for _, dir := range localDirs {
|
||||
if msg := pushTypeConflictError(dir, true, remoteFiles, remoteFolders); msg != "" {
|
||||
typeConflicts[dir] = msg
|
||||
}
|
||||
}
|
||||
for rel := range localByRel {
|
||||
if msg := pushTypeConflictError(rel, false, remoteFiles, remoteFolders); msg != "" {
|
||||
typeConflicts[rel] = msg
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 diff:双端都存在的文件复用 judgeFileMatch,保持与 status 完全一致的判定。
|
||||
diff := driveSyncDiff{
|
||||
NewLocal: []driveStatusEntry{},
|
||||
NewRemote: []driveStatusEntry{},
|
||||
Modified: []driveStatusEntry{},
|
||||
Unchanged: []driveStatusEntry{},
|
||||
Unknown: []driveStatusEntry{},
|
||||
}
|
||||
var newLocal, newRemote, modified, unknown []string
|
||||
for rel, pf := range localByRel {
|
||||
if syncPathBlockedByTypeConflict(rel, typeConflicts) {
|
||||
continue
|
||||
}
|
||||
rf, ok := remoteFiles[rel]
|
||||
if !ok {
|
||||
newLocal = append(newLocal, rel)
|
||||
continue
|
||||
}
|
||||
verdict, jerr := judgePinnedPushFileMatch(pinnedRoot, pf, rf, quick)
|
||||
if jerr != nil {
|
||||
return jerr
|
||||
}
|
||||
switch verdict {
|
||||
case matchUnchanged:
|
||||
diff.Unchanged = append(diff.Unchanged, driveStatusEntry{RelPath: rel})
|
||||
case matchUnknown:
|
||||
unknown = append(unknown, rel)
|
||||
default:
|
||||
modified = append(modified, rel)
|
||||
}
|
||||
}
|
||||
for rel := range remoteFiles {
|
||||
if syncPathBlockedByTypeConflict(rel, typeConflicts) {
|
||||
continue
|
||||
}
|
||||
if _, ok := localByRel[rel]; !ok {
|
||||
newRemote = append(newRemote, rel)
|
||||
}
|
||||
}
|
||||
sort.Strings(newLocal)
|
||||
sort.Strings(newRemote)
|
||||
sort.Strings(modified)
|
||||
sort.Strings(unknown)
|
||||
for _, rel := range newLocal {
|
||||
diff.NewLocal = append(diff.NewLocal, driveStatusEntry{RelPath: rel})
|
||||
}
|
||||
for _, rel := range newRemote {
|
||||
diff.NewRemote = append(diff.NewRemote, driveStatusEntry{RelPath: rel})
|
||||
}
|
||||
for _, rel := range modified {
|
||||
diff.Modified = append(diff.Modified, driveStatusEntry{RelPath: rel})
|
||||
}
|
||||
for _, rel := range unknown {
|
||||
diff.Unknown = append(diff.Unknown, driveStatusEntry{RelPath: rel})
|
||||
}
|
||||
sortEntries(diff.Unchanged)
|
||||
|
||||
res := driveSyncResult{Detection: detection, Diff: diff, Items: []driveSyncItem{}}
|
||||
conflictRels := make([]string, 0, len(typeConflicts))
|
||||
for rel := range typeConflicts {
|
||||
conflictRels = append(conflictRels, rel)
|
||||
}
|
||||
sort.Strings(conflictRels)
|
||||
for _, rel := range conflictRels {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{
|
||||
RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict,
|
||||
Error: typeConflicts[rel],
|
||||
})
|
||||
}
|
||||
if err := pinnedRoot.verify(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// --dry-run:只算差异、不执行任何同步动作。
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(driveSyncDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res,
|
||||
})
|
||||
}
|
||||
// 镜像前置检查是整批门禁:发现任一本地非法名称或跨端等价路径歧义后,
|
||||
// sync 不得执行 create/download/upload 等任一写动作。
|
||||
if len(preflight) > 0 {
|
||||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||||
return perr
|
||||
}
|
||||
return &driveSyncFailure{failed: res.Summary.Failed}
|
||||
}
|
||||
|
||||
// unknown:exact 模式下远端无可靠 md5、内容无法核对,不擅自覆盖任何一侧,记 skipped。
|
||||
for _, rel := range unknown {
|
||||
res.Summary.Skipped++
|
||||
res.Items = append(res.Items, driveSyncItem{
|
||||
RelPath: rel, Action: syncActionSkipped, Direction: syncDirectionConflict,
|
||||
Error: "远端无可靠 md5,内容无法核对,已跳过(可改用 --quick 按 modified_time 比对)",
|
||||
})
|
||||
}
|
||||
|
||||
// ask 模式:先把所有 modified 的解决策略问全,再统一执行,避免边执行边交互。
|
||||
resolutions := make(map[string]string, len(modified))
|
||||
for _, rel := range modified {
|
||||
strategy := onConflict
|
||||
switch strategy {
|
||||
case syncConflictAsk:
|
||||
strategy, err = driveSyncAskConflict(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case syncConflictSkip:
|
||||
strategy = "" // 与 ask 选择「跳过」同一条落地路径
|
||||
}
|
||||
resolutions[rel] = strategy
|
||||
}
|
||||
// 交互决策期间本地根也可能被替换;首个双端写动作前再次核对固定身份。
|
||||
if err := pinnedRoot.verify(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// occupied:keep-both 生成不冲突的本地重命名目标时用,覆盖两侧全部已知路径。
|
||||
occupied := make(map[string]bool)
|
||||
for rel := range localByRel {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for _, d := range localDirs {
|
||||
occupied[d] = true
|
||||
}
|
||||
for rel := range remoteFiles {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for rel := range remoteFolders {
|
||||
if rel != "" {
|
||||
occupied[rel] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段 1:镜像本地目录结构到远端(缺失则创建),保证空目录与 push 目标的父目录先存在。
|
||||
for _, dir := range localDirs {
|
||||
if _, ok := remoteFolders[dir]; ok {
|
||||
continue // 远端已存在,复用 fileId
|
||||
}
|
||||
parentRel, name := splitRel(dir)
|
||||
parentID, ok := remoteFolders[parentRel]
|
||||
if !ok || parentID == "" {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
if err := pinnedRoot.verify(); err != nil {
|
||||
return err
|
||||
}
|
||||
fid, cerr := pushCreateFolder(ctx, spaceID, parentID, name)
|
||||
if cerr != nil || fid == "" {
|
||||
res.Summary.Failed++
|
||||
msg := "create_folder 未返回 fileId"
|
||||
if cerr != nil {
|
||||
msg = cerr.Error()
|
||||
}
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: msg})
|
||||
continue
|
||||
}
|
||||
remoteFolders[dir] = fid
|
||||
res.Summary.Pushed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFolderCreated, Direction: syncDirectionPush})
|
||||
}
|
||||
|
||||
// 阶段 2:new_remote 下载到本地。
|
||||
for _, rel := range newRemote {
|
||||
syncPullFilePinned(&res, ctx, spaceID, remoteFiles[rel], pinnedRoot, rel, syncDirectionPull)
|
||||
}
|
||||
|
||||
// 阶段 3:new_local 上传到远端。
|
||||
for _, rel := range newLocal {
|
||||
pf := localByRel[rel]
|
||||
syncPushFile(&res, ctx, spaceID, remoteFolders, pinnedRoot, pf, rel, "")
|
||||
}
|
||||
|
||||
// 阶段 4:modified 按 --on-conflict 解决。
|
||||
for _, rel := range modified {
|
||||
pf := localByRel[rel]
|
||||
rf := remoteFiles[rel]
|
||||
switch resolutions[rel] {
|
||||
case syncConflictRemoteWins:
|
||||
syncPullFilePinned(&res, ctx, spaceID, rf, pinnedRoot, rel, syncDirectionPull)
|
||||
case syncConflictLocalWins:
|
||||
syncPushFile(&res, ctx, spaceID, remoteFolders, pinnedRoot, pf, rel, rf.FileID)
|
||||
case syncConflictKeepBoth:
|
||||
syncKeepBothPinned(&res, ctx, spaceID, rf, pinnedRoot, rel, occupied)
|
||||
default: // "" — ask 选择跳过
|
||||
res.Summary.Skipped++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionSkipped, Direction: syncDirectionConflict})
|
||||
}
|
||||
}
|
||||
|
||||
// 结构化结果始终打印到 stdout;有失败则额外以非零退出码退出。
|
||||
if perr := deps.Out.PrintJSON(res); perr != nil {
|
||||
return perr
|
||||
}
|
||||
if res.Summary.Failed > 0 {
|
||||
return &driveSyncFailure{failed: res.Summary.Failed}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。
|
||||
// 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。
|
||||
func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool {
|
||||
for rel != "" {
|
||||
if _, blocked := conflicts[rel]; blocked {
|
||||
return true
|
||||
}
|
||||
rel, _ = splitRel(rel)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// addPinnedLocalTargetPreflightFailures 从 sync 已固定的本地根核对所有远端落盘点。
|
||||
// 每一层既有祖先都必须是真实目录,末段若存在则必须是常规文件;软链即使仍指向根内
|
||||
// 也不属于可移植镜像树,必须在任何双端写之前整批拒绝。
|
||||
func addPinnedLocalTargetPreflightFailures(root *pinnedPullRoot, remote map[string]*remoteFile, failures map[string]string) {
|
||||
for rel := range remote {
|
||||
if mirrorPreflightFailureForRel(rel, failures) != "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(filepath.FromSlash(rel), string(filepath.Separator))
|
||||
for i := range parts {
|
||||
name := filepath.Join(parts[:i+1]...)
|
||||
info, err := pullRootLstat(root.root, name)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
failures[rel] = fmt.Sprintf("检查本地目标失败: %v", err)
|
||||
break
|
||||
}
|
||||
if i < len(parts)-1 {
|
||||
if !info.IsDir() {
|
||||
failures[rel] = fmt.Sprintf("本地目标 %q 的祖先 %q 已存在且不是目录,已拒绝镜像", rel, filepath.ToSlash(name))
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
failures[rel] = fmt.Sprintf("本地目标 %q 已存在且不是常规文件,已拒绝覆盖", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncPullFile 下载单个远端文件到本地 rel 对应路径(总是覆盖,Drive 为该项权威源),
|
||||
// 并把结果计入 res。命中大小写/规范化冲突或逃逸的路径记 failed、不落盘。
|
||||
func syncPullFile(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel, direction string) {
|
||||
root, err := openPinnedPullRoot(absDir)
|
||||
if err != nil {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: direction, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
defer root.close()
|
||||
syncPullFilePinned(res, ctx, spaceID, rf, root, rel, direction)
|
||||
}
|
||||
|
||||
func syncPullFilePinned(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, root *pinnedPullRoot, rel string, direction string) {
|
||||
action, perr := pullRemoteFilePinned(ctx, spaceID, rf, root, rel, ifExistsOverwrite)
|
||||
if action == pullActionDownloaded {
|
||||
res.Summary.Pulled++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionDownloaded, Direction: direction})
|
||||
return
|
||||
}
|
||||
res.Summary.Failed++
|
||||
item := driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: direction}
|
||||
if perr != nil {
|
||||
item.Error = perr.Error()
|
||||
}
|
||||
res.Items = append(res.Items, item)
|
||||
}
|
||||
|
||||
// syncPushFile 上传单个本地文件到远端;overwriteID 非空时走覆盖上传(原地覆盖同名远端文件)。
|
||||
func syncPushFile(res *driveSyncResult, ctx context.Context, spaceID string, remoteFolders map[string]string, root *pinnedPullRoot, pf localPushFile, rel, overwriteID string) {
|
||||
parentRel, name := splitRel(rel)
|
||||
parentID, ok := remoteFolders[parentRel]
|
||||
if !ok || parentID == "" {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
|
||||
return
|
||||
}
|
||||
if err := pushUploadFilePinned(ctx, spaceID, parentID, overwriteID, name, root, pf); err != nil {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
res.Summary.Pushed++
|
||||
action := syncActionUploaded
|
||||
direction := syncDirectionPush
|
||||
if overwriteID != "" {
|
||||
action = syncActionOverwritten
|
||||
direction = syncDirectionConflict
|
||||
}
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: action, Direction: direction})
|
||||
}
|
||||
|
||||
// syncKeepBoth 解决 modified 冲突的 keep-both 策略:先为本地文件原子建立带冲突后缀
|
||||
// 的 no-clobber 硬链接,再把远端文件拉取到原 rel 路径。拉取失败时保留两个本地名字,
|
||||
// 不执行可能误删或覆盖并发文件的回滚。
|
||||
func syncKeepBoth(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, absDir, rel string, occupied map[string]bool) {
|
||||
root, err := openPinnedPullRoot(absDir)
|
||||
if err != nil {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
defer root.close()
|
||||
syncKeepBothPinned(res, ctx, spaceID, rf, root, rel, occupied)
|
||||
}
|
||||
|
||||
func syncKeepBothPinned(res *driveSyncResult, ctx context.Context, spaceID string, rf *remoteFile, root *pinnedPullRoot, rel string, occupied map[string]bool) {
|
||||
pinned, e1 := root.openTarget(rel)
|
||||
if e1 != nil {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: e1.Error()})
|
||||
return
|
||||
}
|
||||
defer pinned.close()
|
||||
localInfo, err := pinned.regularTargetInfo()
|
||||
if err != nil {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if localInfo == nil {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: "本地保留版本不存在"})
|
||||
return
|
||||
}
|
||||
// Root.Link(original,candidate) 在同一固定父目录中原子 no-clobber 地保留本地版本。
|
||||
// occupied 的精确字符串查重覆盖不到的文件系统等价名称,由 Link 的 EEXIST 兜底。
|
||||
suffixedRel, newName, e2 := reserveSyncKeepBothTargetPinned(pinned, rel, rf.FileID, occupied)
|
||||
if e2 != nil {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: e2.Error()})
|
||||
return
|
||||
}
|
||||
savedInfo, err := pullRootLstat(pinned.parentRoot, newName)
|
||||
if err != nil || !sameKeepBothVersion(localInfo, savedInfo) {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: "本地保留版本身份不一致"})
|
||||
return
|
||||
}
|
||||
occupied[suffixedRel] = true
|
||||
|
||||
action, perr := syncPullOneFilePinned(ctx, spaceID, rf, pinned, ifExistsOverwrite)
|
||||
if action != pullActionDownloaded {
|
||||
res.Summary.Failed++
|
||||
msg := ""
|
||||
if perr != nil {
|
||||
msg = perr.Error()
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "远端版本未能拉取,本地保留版本已保留"
|
||||
}
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: msg})
|
||||
return
|
||||
}
|
||||
currentSaved, err := pullRootLstat(pinned.parentRoot, newName)
|
||||
if err != nil || !sameKeepBothVersion(savedInfo, currentSaved) {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionConflict, Error: "本地保留版本在传输期间被删除、替换或修改"})
|
||||
return
|
||||
}
|
||||
res.Summary.Pulled++
|
||||
res.Items = append(res.Items,
|
||||
driveSyncItem{RelPath: suffixedRel, Action: syncActionRenamedLocal, Direction: syncDirectionConflict},
|
||||
driveSyncItem{RelPath: rel, Action: syncActionDownloaded, Direction: syncDirectionPull},
|
||||
)
|
||||
}
|
||||
|
||||
func sameKeepBothVersion(expected, current os.FileInfo) bool {
|
||||
return expected != nil && current != nil && current.Mode().IsRegular() &&
|
||||
os.SameFile(expected, current) && expected.Size() == current.Size() &&
|
||||
expected.ModTime().Equal(current.ModTime())
|
||||
}
|
||||
|
||||
// syncKeepBothCandidate 生成 keep-both 的第 n 个候选相对路径(n=0 为首选):在扩展名前
|
||||
// 插入基于远端 fileId 末段的后缀(缺失时用 conflict),n>0 再追加序号消歧。
|
||||
func syncKeepBothCandidate(rel, fileID string, n int) string {
|
||||
suffix := "conflict"
|
||||
if fileID != "" {
|
||||
s := fileID
|
||||
if len(s) > 8 {
|
||||
s = s[len(s)-8:]
|
||||
}
|
||||
suffix = "conflict-" + s
|
||||
}
|
||||
dir, base := splitRel(rel)
|
||||
ext := filepath.Ext(base) // base 无路径分隔符,取末尾扩展名安全
|
||||
stem := strings.TrimSuffix(base, ext)
|
||||
name := stem + "." + suffix + ext
|
||||
if n > 0 {
|
||||
name = fmt.Sprintf("%s.%s.%d%s", stem, suffix, n, ext)
|
||||
}
|
||||
if dir == "" {
|
||||
return name
|
||||
}
|
||||
return dir + "/" + name
|
||||
}
|
||||
|
||||
// driveSyncSuffixedRel 返回首个不在 occupied(纯字符串查重)中的候选。仅用于快速路径与
|
||||
// 单元测试;keep-both 实际执行走 reserveSyncKeepBothTarget,以 OS 兜底等价性。
|
||||
func driveSyncSuffixedRel(rel, fileID string, occupied map[string]bool) string {
|
||||
for n := 0; ; n++ {
|
||||
if cand := syncKeepBothCandidate(rel, fileID, n); !occupied[cand] {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reserveSyncKeepBothTarget 生成候选并通过 Root.Link(original,candidate) 原子建立
|
||||
// no-clobber 本地保留版本。EEXIST(含文件系统等价名称)时换下一个候选。
|
||||
func reserveSyncKeepBothTarget(absDir, rel, fileID string, occupied map[string]bool) (string, string, error) {
|
||||
target, err := openPinnedPullTarget(absDir, rel)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer target.close()
|
||||
candidate, _, err := reserveSyncKeepBothTargetPinned(target, rel, fileID, occupied)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return candidate, filepath.Join(absDir, filepath.FromSlash(candidate)), nil
|
||||
}
|
||||
|
||||
func reserveSyncKeepBothTargetPinned(target *pinnedPullTarget, rel, fileID string, occupied map[string]bool) (string, string, error) {
|
||||
parentRel, _ := splitRel(rel)
|
||||
for n := 0; ; n++ {
|
||||
cand := syncKeepBothCandidate(rel, fileID, n)
|
||||
if occupied[cand] {
|
||||
continue // 本次运行内已知占用,快速跳过
|
||||
}
|
||||
candidateParent, candidateName := splitRel(cand)
|
||||
if candidateParent != parentRel || !isSafeRemoteSegment(candidateName) {
|
||||
return "", "", fmt.Errorf("keep-both 候选路径 %q 不在原目标目录内", cand)
|
||||
}
|
||||
if err := target.verifyParent(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
oerr := syncRootLink(target.parentRoot, target.name, filepath.FromSlash(candidateName))
|
||||
if oerr == nil {
|
||||
return cand, filepath.FromSlash(candidateName), nil
|
||||
}
|
||||
if os.IsExist(oerr) {
|
||||
occupied[cand] = true // 等价目标已存在,记下避免重试,换下一个后缀
|
||||
continue
|
||||
}
|
||||
return "", "", oerr
|
||||
}
|
||||
}
|
||||
|
||||
// syncAskStdin 是 --on-conflict=ask 交互提问的输入源,默认 os.Stdin;测试可替换它以
|
||||
// 注入非交互(EOF)场景。
|
||||
var syncAskStdin io.Reader = os.Stdin
|
||||
|
||||
// driveSyncAskConflict 在 --on-conflict=ask 下交互式询问单个冲突文件的解决策略。
|
||||
// 返回四种具体策略之一,或 ""(跳过)。当 stdin 在给出选择前结束(管道/无 TTY 等非交互
|
||||
// 环境)时,按文档约定等价于「跳过」,返回 ""——绝不因此中止整个同步,new_local/new_remote
|
||||
// 等其余差异仍会照常处理。
|
||||
func driveSyncAskConflict(rel string) (string, error) {
|
||||
fmt.Fprintf(os.Stderr, "冲突: 本地与远端都修改了 %q。请选择 [R]远端优先 / [L]本地优先 / [K]保留两者 / [S]跳过 (默认 R): ", rel)
|
||||
line, err := bufio.NewReader(syncAskStdin).ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", fmt.Errorf("读取冲突选择失败 (%s): %w", rel, err)
|
||||
}
|
||||
switch strings.TrimSpace(strings.ToLower(line)) {
|
||||
case "r", "remote", "remote-wins":
|
||||
return syncConflictRemoteWins, nil
|
||||
case "l", "local", "local-wins":
|
||||
return syncConflictLocalWins, nil
|
||||
case "k", "keep", "keep-both":
|
||||
return syncConflictKeepBoth, nil
|
||||
case "s", "skip":
|
||||
return "", nil
|
||||
case "":
|
||||
if errors.Is(err, io.EOF) {
|
||||
// 非交互(管道/无 TTY):stdin 在给出选择前结束。按文档约定等价于跳过,
|
||||
// 返回 "" 而非报错——否则单个冲突会让 runDriveSync 直接中止,连
|
||||
// new_local/new_remote 都不再处理。
|
||||
return "", nil
|
||||
}
|
||||
return syncConflictRemoteWins, nil // 交互式回车 → 默认远端优先
|
||||
default:
|
||||
return "", fmt.Errorf("无效的冲突选择: %q(可选 remote/local/keep/skip)", strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// pull / push / sync 声明 Safety.Confirmation = user_required。本文件断言两件事:
|
||||
// 1. 未确认(无 --yes、非 dry-run)时命令必须在任何写操作之前拒绝执行;
|
||||
// 2. 明确确认(--yes)后才发出精确的工具调用并落盘。
|
||||
// status 是只读叶子(not_required),不受确认门约束。
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 1. 未确认即拒绝,且一个写操作都不发生
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncFamily_refusesWithoutConfirmation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
setup func(t *testing.T, dir string)
|
||||
listJSON string
|
||||
}{
|
||||
{
|
||||
name: "pull",
|
||||
args: []string{"pull"},
|
||||
// 远端有文件、本地为空:即便是纯新增也必须先确认。
|
||||
listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`,
|
||||
},
|
||||
{
|
||||
name: "push",
|
||||
args: []string{"push"},
|
||||
setup: func(t *testing.T, dir string) { mustWrite(t, filepath.Join(dir, "a.txt"), "local") },
|
||||
listJSON: `{"result":{"items":[],"nextToken":""}}`,
|
||||
},
|
||||
{
|
||||
name: "sync",
|
||||
args: []string{"sync", "--quick", "--on-conflict", "remote-wins"},
|
||||
setup: func(t *testing.T, dir string) { mustWrite(t, filepath.Join(dir, "a.txt"), "local") },
|
||||
listJSON: `{"result":{"items":[{"name":"b.txt","type":"file","fileId":"B","modifyTime":2}],"nextToken":""}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if tc.setup != nil {
|
||||
tc.setup(t, dir)
|
||||
}
|
||||
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
|
||||
switch tool {
|
||||
case "list_files":
|
||||
if nth > 0 {
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}
|
||||
return tc.listJSON, nil
|
||||
case "download_file":
|
||||
return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil
|
||||
case "get_upload_info":
|
||||
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
||||
}
|
||||
return `{"result":{"fileId":"NEW"},"success":true}`, nil
|
||||
}}
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
t.Error("no download may happen before confirmation")
|
||||
return os.WriteFile(dest, []byte("leaked"), 0o644)
|
||||
})
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
t.Error("no upload may happen before confirmation")
|
||||
return nil
|
||||
})
|
||||
|
||||
args := append(append([]string{}, tc.args...), "--local-folder", dir, "--remote-folder", "ROOT")
|
||||
err := runDriveCmdWithoutConfirm(t, caller, args...)
|
||||
if err == nil {
|
||||
t.Fatal("expected the command to refuse without --yes")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--yes") {
|
||||
t.Errorf("error must tell the user to confirm with --yes, got %q", err.Error())
|
||||
}
|
||||
// 拒绝必须发生在任何写工具调用之前。
|
||||
for _, tool := range []string{"download_file", "get_upload_info", "create_folder", "ln"} {
|
||||
if got := caller.callsFor(tool); len(got) != 0 {
|
||||
t.Errorf("%s must not be called before confirmation, got %v", tool, got)
|
||||
}
|
||||
}
|
||||
// 本地目录不得新增任何文件。
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if e.Name() == "b.txt" || e.Name() == "a.txt" && tc.name == "pull" {
|
||||
t.Errorf("%s must not be materialized before confirmation", e.Name())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --dry-run 无需 --yes 也能预览:这是让用户看清将发生什么的必要出口,且不写任何东西。
|
||||
func TestCrossPlatformCoverageDriveSyncFamily_dryRunNeedsNoConfirmation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "l")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}],"nextToken":""}}`,
|
||||
})
|
||||
caller.dryRun = true
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
t.Error("dry-run must not download")
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
t.Error("dry-run must not upload")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := runDriveCmdWithoutConfirm(t, caller, "sync",
|
||||
"--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
|
||||
t.Fatalf("dry-run must not require confirmation: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "remote.txt")); !os.IsNotExist(err) {
|
||||
t.Error("dry-run must not write remote files locally")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_dryRunPlansWithoutLocalWrites(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
target := filepath.Join(parent, "not-created")
|
||||
caller := pullListingCaller(`{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}`)
|
||||
caller.dryRun = true
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
t.Error("dry-run must not download")
|
||||
return nil
|
||||
})
|
||||
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"})
|
||||
cmd := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": target, "remote-folder": "ROOT"})
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("dry-run pull: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
||||
t.Fatalf("dry-run must not create local root: %v", err)
|
||||
}
|
||||
if got := len(caller.callsFor("download_file")); got != 0 {
|
||||
t.Fatalf("download_file calls = %d, want 0", got)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"remote.txt"`) {
|
||||
t.Fatalf("missing pull plan: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "sub", "local.txt"), "local")
|
||||
caller := syncCaller(nil)
|
||||
caller.dryRun = true
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
||||
t.Error("dry-run must not upload")
|
||||
return nil
|
||||
})
|
||||
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
|
||||
cmd := findDriveSubcommand(t, "push")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("dry-run push: %v", err)
|
||||
}
|
||||
for _, tool := range []string{"create_folder", "get_upload_info", "commit_upload"} {
|
||||
if got := len(caller.callsFor(tool)); got != 0 {
|
||||
t.Fatalf("%s calls = %d, want 0", tool, got)
|
||||
}
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"sub/local.txt"`) {
|
||||
t.Fatalf("missing push plan: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
|
||||
mustWrite(t, filepath.Join(root, "smart.txt"), "smart")
|
||||
remote := map[string]*remoteFile{
|
||||
"A.txt": {RelPath: "A.txt"},
|
||||
"a.txt": {RelPath: "a.txt"},
|
||||
"../escape": {RelPath: "../escape"},
|
||||
"skip.txt": {RelPath: "skip.txt"},
|
||||
"smart.txt": {RelPath: "smart.txt", ModifiedTime: 1, ModifiedTimeValid: true},
|
||||
"new.txt": {RelPath: "new.txt"},
|
||||
}
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}})
|
||||
|
||||
if err := printDrivePullDryRun(root, ifExistsSkip, remote,
|
||||
[]string{"A.txt", "a.txt", "../escape", "skip.txt", "new.txt"}, true); err != nil {
|
||||
t.Fatalf("skip plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pullActionFailed) || !strings.Contains(text, pullActionSkipped) || !strings.Contains(text, pullActionDownloaded) {
|
||||
t.Fatalf("skip plan did not cover failed/skipped/downloaded: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
if err := printDrivePullDryRun(root, ifExistsSmart, remote, []string{"smart.txt"}, false); err != nil {
|
||||
t.Fatalf("smart plan: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), pullActionSkipped) {
|
||||
t.Fatalf("smart plan must skip newer local file: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}})
|
||||
remoteFolders := map[string]string{"": "ROOT", "existing": "EXISTING"}
|
||||
remoteFiles := map[string]*remoteFile{
|
||||
"skip.txt": {RelPath: "skip.txt"},
|
||||
"smart-skip.txt": {RelPath: "smart-skip.txt", ModifiedTime: 20, ModifiedTimeValid: true},
|
||||
"smart-overwrite.txt": {RelPath: "smart-overwrite.txt", ModifiedTime: 1, ModifiedTimeValid: true},
|
||||
"overwrite.txt": {RelPath: "overwrite.txt"},
|
||||
}
|
||||
files := func(paths ...string) []localPushFile {
|
||||
result := make([]localPushFile, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
result = append(result, localPushFile{RelPath: path, Size: 1, ModTimeMillis: 10})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if err := printDrivePushDryRun(ifExistsSkip, remoteFiles, remoteFolders,
|
||||
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
|
||||
t.Fatalf("skip plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) {
|
||||
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
if err := printDrivePushDryRun(ifExistsSmart, remoteFiles, remoteFolders, nil,
|
||||
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
|
||||
t.Fatalf("smart plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) {
|
||||
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
|
||||
t.Fatalf("overwrite plan: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), pushActionOverwritten) {
|
||||
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 2. 明确确认后才发出精确的工具调用
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// sync --on-conflict remote-wins --yes:确认后只下载指定远端文件,且不上传。
|
||||
func TestCrossPlatformCoverageDriveSync_confirmedRemoteWinsIssuesExactCalls(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-version")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withSyncTransport(t, "remote-version")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir,
|
||||
"--remote-folder", "ROOT", "--quick", "--on-conflict", "remote-wins", "--yes"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
dl := caller.callsFor("download_file")
|
||||
if len(dl) != 1 || dl[0].args["fileId"] != "F" {
|
||||
t.Fatalf("expected exactly one download_file(fileId=F), got %v", dl)
|
||||
}
|
||||
if up := caller.callsFor("get_upload_info"); len(up) != 0 {
|
||||
t.Errorf("remote-wins must not upload, got %v", up)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "remote-version" {
|
||||
t.Errorf("remote-wins must replace the local content, got %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// pull --if-exists overwrite --yes:确认后才 download_file(fileId=A) 并覆盖本地内容。
|
||||
func TestCrossPlatformCoverageDrivePull_confirmedOverwriteIssuesExactCalls(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "a.txt")
|
||||
mustWrite(t, p, "local-old")
|
||||
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`)
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("remote-new"), 0o644)
|
||||
})
|
||||
|
||||
if err := runDriveCmdWithoutConfirm(t, caller, "pull", "--local-folder", dir,
|
||||
"--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
dl := caller.callsFor("download_file")
|
||||
if len(dl) != 1 || dl[0].args["fileId"] != "A" {
|
||||
t.Fatalf("expected exactly one download_file(fileId=A), got %v", dl)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "remote-new" {
|
||||
t.Errorf("confirmed overwrite must replace the local file, got %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// push --if-exists overwrite --yes:确认后原地覆盖,必须带 overwriteFileId 且不带 parentId。
|
||||
func TestCrossPlatformCoverageDrivePush_confirmedOverwriteIssuesExactCalls(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
|
||||
withNoopPut(t)
|
||||
|
||||
caller := pushOKCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmdWithoutConfirm(t, caller, "push", "--local-folder", dir,
|
||||
"--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
up := caller.callsFor("get_upload_info")
|
||||
if len(up) != 1 || up[0].args["overwriteFileId"] != "A" {
|
||||
t.Fatalf("expected get_upload_info(overwriteFileId=A), got %v", up)
|
||||
}
|
||||
if _, has := up[0].args["parentId"]; has {
|
||||
t.Error("in-place overwrite must not carry parentId")
|
||||
}
|
||||
}
|
||||
|
||||
// sync --on-conflict local-wins --yes:确认后覆盖上传远端,且不下载。
|
||||
func TestCrossPlatformCoverageDriveSync_confirmedLocalWinsIssuesExactCalls(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-version")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withSyncTransport(t, "unused")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir,
|
||||
"--remote-folder", "ROOT", "--quick", "--on-conflict", "local-wins", "--yes"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
up := caller.callsFor("get_upload_info")
|
||||
if len(up) != 1 || up[0].args["overwriteFileId"] != "F" {
|
||||
t.Fatalf("expected get_upload_info(overwriteFileId=F), got %v", up)
|
||||
}
|
||||
if dl := caller.callsFor("download_file"); len(dl) != 0 {
|
||||
t.Errorf("local-wins must not download, got %v", dl)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "local-version" {
|
||||
t.Errorf("local-wins must keep the local content, got %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 3. 安全默认值本身
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// sync 默认 --on-conflict=skip:两侧都变更时两边内容都保留,且不发出任何传输调用。
|
||||
func TestCrossPlatformCoverageDriveSync_defaultSkipsConflictsEntirely(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-version")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withSyncTransport(t, "remote-version")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 {
|
||||
t.Errorf("default skip must not transfer anything, got %d calls", got)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "local-version" {
|
||||
t.Errorf("default skip must keep the local content, got %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// pull 默认 --if-exists=skip:本地已存在的文件不被覆盖。
|
||||
func TestCrossPlatformCoverageDrivePull_defaultSkipsExistingLocalFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "a.txt")
|
||||
mustWrite(t, p, "local-old")
|
||||
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`)
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
t.Error("default skip must not download over an existing local file")
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "local-old" {
|
||||
t.Errorf("default skip must keep the local file, got %q", string(b))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// failFolderCaller 让指定名字的 create_folder 失败,其余一切正常,
|
||||
// 用于构造「父目录未能创建 → 子目录/子文件连锁失败」。
|
||||
func failFolderCaller(failName string) *driveScriptCaller {
|
||||
return &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) {
|
||||
switch tool {
|
||||
case "list_files":
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
case "create_folder":
|
||||
if args["name"] == failName {
|
||||
return "", errors.New("create_folder rejected: " + failName)
|
||||
}
|
||||
return `{"result":{"fileId":"NEWDIR"},"success":true}`, nil
|
||||
case "get_upload_info":
|
||||
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
||||
case "download_file":
|
||||
return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil
|
||||
}
|
||||
return `{"result":{"fileId":"NEWFILE"},"success":true}`, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// mkNested 建出 a/b/deep.txt 结构。
|
||||
func mkNested(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "a", "b"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "a", "b", "deep.txt"), "d")
|
||||
return dir
|
||||
}
|
||||
|
||||
// 顶层目录 a 建失败 → 子目录 a/b 因父目录缺失也 failed,其中文件同样 failed。
|
||||
func TestCrossPlatformCoverageDrivePush_nestedDirFailsWhenParentMissing(t *testing.T) {
|
||||
dir := mkNested(t)
|
||||
withNoopPut(t)
|
||||
|
||||
caller := failFolderCaller("a")
|
||||
err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT")
|
||||
var pf *drivePushFailure
|
||||
if !errors.As(err, &pf) {
|
||||
t.Fatalf("expected drivePushFailure, got %T %v", err, err)
|
||||
}
|
||||
// a(建失败)+ a/b(父缺失)+ a/b/deep.txt(父缺失)= 3
|
||||
if pf.failed != 3 {
|
||||
t.Errorf("failed = %d, want 3", pf.failed)
|
||||
}
|
||||
if got := caller.callsFor("get_upload_info"); len(got) != 0 {
|
||||
t.Errorf("nothing may upload without a parent folder, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSync_nestedDirFailsWhenParentMissing(t *testing.T) {
|
||||
dir := mkNested(t)
|
||||
withNoopPut(t)
|
||||
|
||||
caller := failFolderCaller("a")
|
||||
err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick")
|
||||
var sf *driveSyncFailure
|
||||
if !errors.As(err, &sf) {
|
||||
t.Fatalf("expected driveSyncFailure, got %T %v", err, err)
|
||||
}
|
||||
if sf.failed != 3 {
|
||||
t.Errorf("failed = %d, want 3", sf.failed)
|
||||
}
|
||||
}
|
||||
|
||||
// new_remote 落盘目标经软链逃逸出本地根 → 记 failed,不写到根目录之外。
|
||||
func TestCrossPlatformCoverageDriveSync_newRemoteEscapeIsFailed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
withSyncTransport(t, "leak")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"sub","type":"folder","fileId":"SUB"}],"nextToken":""}}`,
|
||||
"SUB": `{"result":{"items":[{"name":"leaked.txt","type":"file","fileId":"L","modifyTime":1}],"nextToken":""}}`,
|
||||
})
|
||||
err := runDriveCmd(t, caller, "sync", "--local-folder", root, "--remote-folder", "ROOT", "--quick")
|
||||
var sf *driveSyncFailure
|
||||
if !errors.As(err, &sf) {
|
||||
t.Fatalf("expected driveSyncFailure, got %T %v", err, err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outside, "leaked.txt")); !os.IsNotExist(statErr) {
|
||||
t.Error("file escaped the local root")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// keep-both 的失败与回滚分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// syncKeepBoth 在 rel 逃逸时直接 failed(resolveLocalTarget 报错)。
|
||||
func TestCrossPlatformCoverageSyncKeepBoth_escapingRelIsFailed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
res := &driveSyncResult{}
|
||||
syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "sub/f.txt", FileID: "FID12345678"},
|
||||
root, "sub/f.txt", map[string]bool{})
|
||||
if res.Summary.Failed != 1 {
|
||||
t.Fatalf("failed = %d, want 1", res.Summary.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
// 原子建立本地保留硬链接失败 → failed。
|
||||
func TestCrossPlatformCoverageSyncKeepBoth_reserveFailureIsFailed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "f.txt"), "local")
|
||||
testseam.Swap(t, &syncRootLink, func(*os.Root, string, string) error {
|
||||
return errors.New("link boom")
|
||||
})
|
||||
|
||||
res := &driveSyncResult{}
|
||||
syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"},
|
||||
root, "f.txt", map[string]bool{})
|
||||
if res.Summary.Failed != 1 {
|
||||
t.Fatalf("failed = %d, want 1", res.Summary.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
// Link 失败不得留下空占位,也不得移动或修改原文件。
|
||||
func TestCrossPlatformCoverageSyncKeepBoth_linkFailureLeavesOriginal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "f.txt"), "local")
|
||||
testseam.Swap(t, &syncRootLink, func(*os.Root, string, string) error {
|
||||
return errors.New("link boom")
|
||||
})
|
||||
res := &driveSyncResult{}
|
||||
occupied := map[string]bool{}
|
||||
syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"},
|
||||
root, "f.txt", occupied)
|
||||
|
||||
if res.Summary.Failed != 1 {
|
||||
t.Fatalf("failed = %d, want 1", res.Summary.Failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, syncKeepBothCandidate("f.txt", "FID12345678", 0))); !os.IsNotExist(err) {
|
||||
t.Error("failed link must not leave a placeholder")
|
||||
}
|
||||
if content, err := os.ReadFile(filepath.Join(root, "f.txt")); err != nil || string(content) != "local" {
|
||||
t.Fatalf("original changed after failed link: content=%q err=%v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取失败时不做回滚:原名尚未发布,候选硬链接也保留,避免误删并发对象。
|
||||
func TestCrossPlatformCoverageSyncKeepBoth_pullFailurePreservesCandidate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "f.txt")
|
||||
mustWrite(t, p, "local-version")
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: syncCaller(nil), Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { return errTestDownload })
|
||||
|
||||
res := &driveSyncResult{}
|
||||
syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"},
|
||||
root, "f.txt", map[string]bool{})
|
||||
|
||||
if res.Summary.Failed != 1 {
|
||||
t.Fatalf("failed = %d, want 1", res.Summary.Failed)
|
||||
}
|
||||
if b, err := os.ReadFile(p); err != nil || string(b) != "local-version" {
|
||||
t.Fatalf("original must remain before publish: %v / %q", err, string(b))
|
||||
}
|
||||
suffixed := filepath.Join(root, syncKeepBothCandidate("f.txt", "FID12345678", 0))
|
||||
if b, err := os.ReadFile(suffixed); err != nil || string(b) != "local-version" {
|
||||
t.Fatalf("candidate must remain after pull failure: %v / %q", err, string(b))
|
||||
}
|
||||
originalInfo, _ := os.Lstat(p)
|
||||
candidateInfo, _ := os.Lstat(suffixed)
|
||||
if !os.SameFile(originalInfo, candidateInfo) {
|
||||
t.Fatal("candidate must be a hard link to the original local version")
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取失败只能上报 failed,不得产生 renamed_local 成功项。
|
||||
func TestCrossPlatformCoverageSyncKeepBoth_pullFailureDoesNotReportRenamed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "f.txt")
|
||||
mustWrite(t, p, "local-version")
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: syncCaller(nil), Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error { return errTestDownload })
|
||||
|
||||
res := &driveSyncResult{}
|
||||
syncKeepBoth(res, context.Background(), "", &remoteFile{RelPath: "f.txt", FileID: "FID12345678"},
|
||||
root, "f.txt", map[string]bool{})
|
||||
|
||||
if res.Summary.Failed != 1 {
|
||||
t.Fatalf("failed = %d, want 1", res.Summary.Failed)
|
||||
}
|
||||
if len(res.Items) != 1 || res.Items[0].Action != syncActionFailed {
|
||||
t.Fatalf("items = %+v", res.Items)
|
||||
}
|
||||
suffixed := syncKeepBothCandidate("f.txt", "FID12345678", 0)
|
||||
if b, err := os.ReadFile(filepath.Join(root, suffixed)); err != nil || string(b) != "local-version" {
|
||||
t.Fatalf("local version must survive as %s: %v / %q", suffixed, err, string(b))
|
||||
}
|
||||
if res.Items[0].Action == syncActionRenamedLocal {
|
||||
t.Fatalf("pull failure must not report renamed_local: %#v", res.Items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// syncCaller 组合 push 与 pull 两侧:list_files 按 parentId 返回远端现状,
|
||||
// create_folder / get_upload_info / ln 走 push 路径,download_file 走 pull 路径。
|
||||
func syncCaller(listing map[string]string) *driveScriptCaller {
|
||||
return &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) {
|
||||
switch tool {
|
||||
case "list_files":
|
||||
parent, _ := args["parentId"].(string)
|
||||
if body, ok := listing[parent]; ok {
|
||||
return body, nil
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
case "create_folder":
|
||||
return `{"result":{"fileId":"NEWDIR"},"success":true}`, nil
|
||||
case "get_upload_info":
|
||||
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
||||
case "download_file":
|
||||
return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil
|
||||
}
|
||||
return `{"result":{"fileId":"NEWFILE"},"success":true}`, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// withSyncTransport 注入成功的上传与下载。
|
||||
func withSyncTransport(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil })
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte(body), 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// --on-conflict 取值处理
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 显式传空 --on-conflict 时回落安全默认 skip:两侧都变更时两边内容都不动。
|
||||
func TestCrossPlatformCoverageDriveSync_emptyOnConflictDefaultsToSkip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-old")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withSyncTransport(t, "remote-new")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT",
|
||||
"--quick", "--on-conflict", ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "local-old" {
|
||||
t.Errorf("empty --on-conflict must default to skip and keep the local file, got %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSync_invalidOnConflictRejected(t *testing.T) {
|
||||
err := runDriveCmd(t, syncCaller(nil), "sync", "--local-folder", t.TempDir(),
|
||||
"--remote-folder", "ROOT", "--on-conflict", "bogus")
|
||||
if err == nil || !strings.Contains(err.Error(), "--on-conflict") {
|
||||
t.Fatalf("expected --on-conflict rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// unchanged / unknown 分类
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// quick 模式下两侧 mtime 相同 → unchanged,不做任何读写。
|
||||
func TestCrossPlatformCoverageDriveSync_unchangedIsUntouched(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "same.txt")
|
||||
mustWrite(t, p, "identical")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withSyncTransport(t, "should-not-be-used")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"same.txt","type":"file","fileId":"S","modifyTime":` +
|
||||
strconv.FormatInt(local, 10) + `}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 {
|
||||
t.Errorf("unchanged file must not be transferred, got %d write calls", got)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "identical" {
|
||||
t.Errorf("unchanged file was modified: %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// exact 模式远端无可靠 md5 → unknown,一律计入 skipped 且不动任何一侧。
|
||||
func TestCrossPlatformCoverageDriveSync_unknownIsSkipped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "u.txt")
|
||||
mustWrite(t, p, "local")
|
||||
withSyncTransport(t, "remote")
|
||||
|
||||
// 无 md5 字段 → exact 模式无法核对内容。
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"u.txt","type":"file","fileId":"U","modifyTime":1}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 {
|
||||
t.Errorf("unknown must not be transferred, got %d write calls", got)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "local" {
|
||||
t.Errorf("unknown file was modified: %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// exact 模式下本地 MD5 计算失败 → 整个 sync 中止。错误通过 seam 确定性注入,
|
||||
// 避免依赖 chmod:Windows 不遵循 POSIX 的 000 权限语义。
|
||||
func TestCrossPlatformCoverageDriveSync_md5FailureAborts(t *testing.T) {
|
||||
testseam.Swap(t, &pushMD5OpenedFile, func(*os.File) (string, error) {
|
||||
return "", errors.New("md5 boom")
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secret.txt")
|
||||
mustWrite(t, p, "data")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"secret.txt","type":"file","fileId":"S","md5":"abc"}],"nextToken":""}}`,
|
||||
})
|
||||
err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT")
|
||||
if err == nil || !strings.Contains(err.Error(), "MD5") {
|
||||
t.Fatalf("expected MD5 failure to abort sync, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// new_local 推送:按需建目录
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 本地子目录在远端缺失 → 建目录(记 folder_created + pushed)再上传其中文件。
|
||||
func TestCrossPlatformCoverageDriveSync_createsRemoteFolderForNewLocal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d")
|
||||
withSyncTransport(t, "unused")
|
||||
|
||||
caller := syncCaller(nil)
|
||||
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
cf := caller.callsFor("create_folder")
|
||||
if len(cf) != 1 || cf[0].args["name"] != "sub" {
|
||||
t.Fatalf("expected create_folder(sub), got %v", cf)
|
||||
}
|
||||
up := caller.callsFor("get_upload_info")
|
||||
if len(up) != 1 || up[0].args["parentId"] != "NEWDIR" {
|
||||
t.Errorf("upload parentId = %v, want NEWDIR", up[0].args["parentId"])
|
||||
}
|
||||
}
|
||||
|
||||
// 远端已有同名目录 → 复用,不重建。
|
||||
func TestCrossPlatformCoverageDriveSync_reusesExistingRemoteFolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d")
|
||||
withSyncTransport(t, "unused")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"sub","type":"folder","fileId":"EXISTING"}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := caller.callsFor("create_folder"); len(got) != 0 {
|
||||
t.Errorf("existing folder must be reused, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// create_folder 失败 → 目录与其中文件都记 failed,命令非零退出。
|
||||
func TestCrossPlatformCoverageDriveSync_folderCreationFailureCascades(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dir, "sub", "deep.txt"), "d")
|
||||
withSyncTransport(t, "unused")
|
||||
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool == "create_folder" {
|
||||
return "", errors.New("create_folder boom")
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}}
|
||||
err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick")
|
||||
var sf *driveSyncFailure
|
||||
if !errors.As(err, &sf) || sf.failed != 2 {
|
||||
t.Fatalf("expected 2 failures (folder + orphaned file), got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// new_local 上传失败 → 记 failed 并非零退出。
|
||||
func TestCrossPlatformCoverageDriveSync_pushUploadFailureIsFailed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "only-local.txt"), "x")
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return errTestUpload })
|
||||
|
||||
caller := syncCaller(nil)
|
||||
err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick")
|
||||
var sf *driveSyncFailure
|
||||
if !errors.As(err, &sf) || sf.failed != 1 {
|
||||
t.Fatalf("expected 1 push failure, got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// --on-conflict ask 的每个交互答案
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncAskConflict_answers(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
isErr bool
|
||||
}{
|
||||
{"r\n", syncConflictRemoteWins, false},
|
||||
{"remote\n", syncConflictRemoteWins, false},
|
||||
{"l\n", syncConflictLocalWins, false},
|
||||
{"local-wins\n", syncConflictLocalWins, false},
|
||||
{"k\n", syncConflictKeepBoth, false},
|
||||
{"keep\n", syncConflictKeepBoth, false},
|
||||
{"s\n", "", false},
|
||||
{"skip\n", "", false},
|
||||
{"\n", syncConflictRemoteWins, false}, // 交互式回车 → 默认远端优先
|
||||
{"nonsense\n", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(strings.TrimSpace(tc.input), func(t *testing.T) {
|
||||
testseam.Swap(t, &syncAskStdin, io.Reader(strings.NewReader(tc.input)))
|
||||
|
||||
got, err := driveSyncAskConflict("f.txt")
|
||||
if tc.isErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q", tc.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("driveSyncAskConflict(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ask 选到无效值时整个 sync 中止(错误上抛,不是静默跳过)。
|
||||
func TestCrossPlatformCoverageDriveSync_askInvalidChoiceAborts(t *testing.T) {
|
||||
testseam.Swap(t, &syncAskStdin, io.Reader(strings.NewReader("nonsense\n")))
|
||||
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withSyncTransport(t, "remote")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT",
|
||||
"--quick", "--on-conflict", "ask"); err == nil {
|
||||
t.Fatal("expected invalid ask choice to abort sync")
|
||||
}
|
||||
}
|
||||
|
||||
// ask 选 keep-both:本地改名保留副本,远端拉到原名。
|
||||
func TestCrossPlatformCoverageDriveSync_askKeepBothViaStdin(t *testing.T) {
|
||||
testseam.Swap(t, &syncAskStdin, io.Reader(strings.NewReader("k\n")))
|
||||
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-version")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withSyncTransport(t, "remote-version")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"FID12345678","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT",
|
||||
"--quick", "--on-conflict", "ask"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "remote-version" {
|
||||
t.Errorf("original path must hold the remote version, got %q", string(b))
|
||||
}
|
||||
// 本地副本以 .conflict-<fileId 末 8 位> 保留。
|
||||
entries, _ := os.ReadDir(dir)
|
||||
var found bool
|
||||
for _, e := range entries {
|
||||
if strings.Contains(e.Name(), ".conflict-") {
|
||||
found = true
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, e.Name())); string(b) != "local-version" {
|
||||
t.Errorf("conflict copy content = %q, want local-version", string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no .conflict-* copy kept, dir = %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// keep-both 改名失败(目标被目录占位)→ 记 failed,本地原文件保持不动。
|
||||
func TestCrossPlatformCoverageDriveSync_keepBothRenameFailureKeepsLocal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-version")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withSyncTransport(t, "remote-version")
|
||||
|
||||
// 让候选改名目标 f.conflict-FID12345.txt 被一个目录占住 → O_EXCL 建占位失败。
|
||||
if err := os.MkdirAll(filepath.Join(dir, "f.conflict-ID123456.txt"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"FID123456","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
})
|
||||
// 无论走到哪个失败分支,本地原文件都不能被破坏。
|
||||
_ = runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT",
|
||||
"--quick", "--on-conflict", "keep-both")
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatalf("local file must still exist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dry-run 与 unknown 组合
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// dry-run 下即使有 new_local / new_remote / modified 也不产生任何写调用。
|
||||
func TestCrossPlatformCoverageDriveSync_dryRunSkipsEveryWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "l")
|
||||
p := filepath.Join(dir, "both.txt")
|
||||
mustWrite(t, p, "local")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
withSyncTransport(t, "remote")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[` +
|
||||
`{"name":"both.txt","type":"file","fileId":"B","modifyTime":` + differentMillis(local) + `},` +
|
||||
`{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2000}` +
|
||||
`],"nextToken":""}}`,
|
||||
})
|
||||
caller.dryRun = true
|
||||
|
||||
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")) +
|
||||
len(caller.callsFor("create_folder")); got != 0 {
|
||||
t.Errorf("dry-run must not write, got %d calls", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "remote.txt")); !os.IsNotExist(err) {
|
||||
t.Error("dry-run must not materialize remote files locally")
|
||||
}
|
||||
}
|
||||
|
||||
// 远端列举失败 → sync 直接报错。
|
||||
func TestCrossPlatformCoverageDriveSync_remoteListFailurePropagates(t *testing.T) {
|
||||
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }}
|
||||
if err := runDriveCmd(t, caller, "sync", "--local-folder", t.TempDir(), "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected remote listing failure to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// 本地根目录不存在 → 扫描失败上抛。
|
||||
func TestCrossPlatformCoverageDriveSync_missingLocalRootFails(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "absent")
|
||||
if err := runDriveCmd(t, syncCaller(nil), "sync", "--local-folder", missing, "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected missing local root to fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var errTestRead = errors.New("simulated read failure")
|
||||
|
||||
// errReader 让 bufio 读取返回非 EOF 错误,覆盖 ask 的读取失败分支。
|
||||
type errReader struct{}
|
||||
|
||||
func (errReader) Read([]byte) (int, error) { return 0, errTestRead }
|
||||
|
||||
// findDriveSubcommand 取出 drive 下的指定子命令,便于直接调用其 RunE。
|
||||
func findDriveSubcommand(t *testing.T, name string) *cobra.Command {
|
||||
t.Helper()
|
||||
for _, c := range newDriveCommand().Commands() {
|
||||
if c.Name() == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatalf("drive subcommand %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustSetFlags 设置一组 flag,任何失败都直接终止用例。
|
||||
func mustSetFlags(t *testing.T, cmd *cobra.Command, kv map[string]string) {
|
||||
t.Helper()
|
||||
for k, v := range kv {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("set --%s=%s: %v", k, v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// --if-exists 显式空值回落默认
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_emptyIfExistsDefaultsToSkip(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "a.txt")
|
||||
mustWrite(t, p, "local-old")
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`)
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("remote-new"), 0o644)
|
||||
})
|
||||
|
||||
if err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT", "--if-exists", ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "local-old" {
|
||||
t.Errorf("empty --if-exists must default to skip and keep the local file, got %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePush_emptyIfExistsDefaultsToSkip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
|
||||
withNoopPut(t)
|
||||
|
||||
caller := pushOKCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`,
|
||||
})
|
||||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := caller.callsFor("get_upload_info"); len(got) != 0 {
|
||||
t.Errorf("empty --if-exists must behave as skip, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// pullOneFile 的落盘失败分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 目标目录不可写 → 创建临时文件失败。
|
||||
func TestCrossPlatformCoveragePullOneFile_tempFileCreationFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
testseam.Swap(t, &pullCreateTemp, func(*os.Root) (*os.File, string, error) {
|
||||
return nil, "", errors.New("create temp boom")
|
||||
})
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
action, err := pullOneFile(context.Background(), "", &remoteFile{RelPath: "a.txt", FileID: "A"},
|
||||
filepath.Join(root, "a.txt"), ifExistsOverwrite)
|
||||
if action != pullActionFailed || err == nil {
|
||||
t.Fatalf("action=%q err=%v, want failed + error", action, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 目标路径是目录 → 最后的原子 rename 失败。
|
||||
func TestCrossPlatformCoveragePullOneFile_renameOntoDirectoryFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "a.txt")
|
||||
if err := os.MkdirAll(target, 0o755); err != nil { // 目标被目录占位
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("payload"), 0o644)
|
||||
})
|
||||
|
||||
action, err := pullOneFile(context.Background(), "", &remoteFile{RelPath: "a.txt", FileID: "A"},
|
||||
target, ifExistsOverwrite)
|
||||
if action != pullActionFailed || err == nil {
|
||||
t.Fatalf("action=%q err=%v, want failed + error", action, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// PrintJSON 失败分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_printFailurePropagates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: pullListingCaller(""), Out: &Formatter{w: failingWriter{}}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"})
|
||||
|
||||
cmd := findDriveSubcommand(t, "pull")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
||||
if err := runDrivePull(cmd, nil); err == nil {
|
||||
t.Fatal("expected the PrintJSON writer failure to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePush_printFailurePropagates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "x")
|
||||
withNoopPut(t)
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: pushOKCaller(nil), Out: &Formatter{w: failingWriter{}}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
|
||||
|
||||
cmd := findDriveSubcommand(t, "push")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
||||
if err := runDrivePush(cmd, nil); err == nil {
|
||||
t.Fatal("expected the PrintJSON writer failure to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSync_printFailurePropagates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "x")
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil })
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: syncCaller(nil), Out: &Formatter{w: failingWriter{}}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
|
||||
|
||||
cmd := findDriveSubcommand(t, "sync")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT", "quick": "true"})
|
||||
if err := runDriveSync(cmd, nil); err == nil {
|
||||
t.Fatal("expected the PrintJSON writer failure to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// walkLocalForPush / walkLocalTree 的 WalkDir 错误回调
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageWalkLocal_unreadableSubdirectoryErrors(t *testing.T) {
|
||||
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
|
||||
t.Skip("unreadable dir needs POSIX perms and a non-root user")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "locked")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(sub, "inner.txt"), "x")
|
||||
if err := os.Chmod(sub, 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(sub, 0o755) })
|
||||
|
||||
if _, _, err := walkLocalForPush(dir); err == nil {
|
||||
t.Error("walkLocalForPush must surface the WalkDir error")
|
||||
}
|
||||
if _, err := walkLocalTree(dir); err == nil {
|
||||
t.Error("walkLocalTree must surface the WalkDir error")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// verifyNoSymlinkEscape 的剩余分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 目标位于 absDir 之外 → 走到根目录之上即放行(不是逃逸判定范围)。
|
||||
func TestCrossPlatformCoverageVerifyNoSymlinkEscape_targetOutsideRootIsIgnored(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "elsewhere", "x.txt")
|
||||
if err := verifyNoSymlinkEscape(root, outside); err != nil {
|
||||
t.Errorf("target above the root must be ignored, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 已存在路径含符号链接环 → EvalSymlinks 失败要如实上报。
|
||||
func TestCrossPlatformCoverageVerifyNoSymlinkEscape_symlinkLoopErrors(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
loop := filepath.Join(root, "loop")
|
||||
if err := os.Symlink(loop, loop); err != nil {
|
||||
t.Skipf("self-symlink unsupported: %v", err)
|
||||
}
|
||||
err := verifyNoSymlinkEscape(root, filepath.Join(loop, "child.txt"))
|
||||
if err == nil || !strings.Contains(err.Error(), "解析路径") {
|
||||
t.Fatalf("expected EvalSymlinks failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// reserveSyncKeepBothTarget 的失败分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 候选目标逃逸出本地根(父目录是指向外部的软链)→ 直接失败。
|
||||
func TestCrossPlatformCoverageReserveSyncKeepBothTarget_escapeFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "sub")); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
if _, _, err := reserveSyncKeepBothTarget(root, "sub/f.txt", "FID12345678", map[string]bool{}); err == nil {
|
||||
t.Fatal("expected escaping keep-both target to fail")
|
||||
}
|
||||
}
|
||||
|
||||
// 候选硬链接创建返回非 EEXIST 错误时直接上报。
|
||||
func TestCrossPlatformCoverageReserveSyncKeepBothTarget_unwritableDirFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "f.txt"), "local")
|
||||
testseam.Swap(t, &syncRootLink, func(*os.Root, string, string) error {
|
||||
return errors.New("link boom")
|
||||
})
|
||||
|
||||
if _, _, err := reserveSyncKeepBothTarget(root, "f.txt", "FID12345678", map[string]bool{}); err == nil {
|
||||
t.Fatal("expected unwritable directory to fail")
|
||||
}
|
||||
}
|
||||
|
||||
// occupied 中已知占用的首选候选被跳过,改用下一个后缀。
|
||||
func TestCrossPlatformCoverageReserveSyncKeepBothTarget_skipsKnownOccupied(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "f.txt"), "local")
|
||||
first := syncKeepBothCandidate("f.txt", "FID12345678", 0)
|
||||
occupied := map[string]bool{first: true}
|
||||
|
||||
got, _, err := reserveSyncKeepBothTarget(root, "f.txt", "FID12345678", occupied)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == first {
|
||||
t.Errorf("known-occupied candidate %q must be skipped", first)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// ask 读取失败(非 EOF)
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncAskConflict_readErrorPropagates(t *testing.T) {
|
||||
testseam.Swap(t, &syncAskStdin, io.Reader(errReader{}))
|
||||
|
||||
if _, err := driveSyncAskConflict("f.txt"); err == nil {
|
||||
t.Fatal("expected read failure to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// sync:本地根不可创建
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// --local-folder 指向一个常规文件 → MkdirAll 失败。
|
||||
func TestCrossPlatformCoverageDriveSync_localRootIsFileFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
asFile := filepath.Join(dir, "not-a-dir")
|
||||
mustWrite(t, asFile, "x")
|
||||
|
||||
if err := runDriveCmd(t, syncCaller(nil), "sync", "--local-folder", asFile, "--remote-folder", "ROOT", "--quick"); err == nil {
|
||||
t.Fatal("expected a regular-file local root to fail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// 本文件补齐几条在默认策略改为 skip 后失去覆盖的边界分支。
|
||||
|
||||
// pull 自动创建目标根失败(--local-folder 指向一个常规文件)→ 明确报错,不落盘。
|
||||
func TestCrossPlatformCoverageDrivePull_localRootIsFileFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
asFile := filepath.Join(dir, "not-a-dir")
|
||||
mustWrite(t, asFile, "x")
|
||||
|
||||
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}`)
|
||||
err := runDriveCmd(t, caller, "pull", "--local-folder", asFile, "--remote-folder", "ROOT")
|
||||
if err == nil || !strings.Contains(err.Error(), "创建本地目录失败") {
|
||||
t.Fatalf("expected local root creation failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// push 遍历远端时 list_files 在**子目录**层失败 → 错误如实上抛(walkRemoteForPush 的递归错误分支)。
|
||||
func TestCrossPlatformCoverageDrivePush_remoteSubdirListFailurePropagates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "x")
|
||||
withNoopPut(t)
|
||||
|
||||
caller := &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
return `{"result":{},"success":true}`, nil
|
||||
}
|
||||
switch args["parentId"] {
|
||||
case "ROOT":
|
||||
return `{"result":{"items":[{"name":"sub","type":"folder","fileId":"SUB"}],"nextToken":""}}`, nil
|
||||
default:
|
||||
return "", errTestList // 递归进入子目录后失败
|
||||
}
|
||||
}}
|
||||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil {
|
||||
t.Fatal("expected the nested list_files failure to propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// push 的远端索引跳过非 file 类型(在线文档/快捷方式),不把它们当成可覆盖的既有文件。
|
||||
func TestCrossPlatformCoverageDrivePush_remoteNonFileEntriesAreSkipped(t *testing.T) {
|
||||
caller := &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
return `{"result":{},"success":true}`, nil
|
||||
}
|
||||
if args["parentId"] == "ROOT" {
|
||||
return `{"result":{"items":[` +
|
||||
`{"name":"online.adoc","type":"document","fileId":"D1"},` +
|
||||
`{"name":"link","type":"shortcut","fileId":"S1"},` +
|
||||
`{"name":"real.txt","type":"file","fileId":"F1"}` +
|
||||
`],"nextToken":""}}`, nil
|
||||
}
|
||||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||||
}}
|
||||
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||||
|
||||
files, _, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := files["real.txt"]; !ok {
|
||||
t.Error("regular file must be indexed")
|
||||
}
|
||||
for _, skipped := range []string{"online.adoc", "link"} {
|
||||
if _, ok := files[skipped]; ok {
|
||||
t.Errorf("non-file entry %q must not be indexed", skipped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// list_files 是远端权威树的数据来源;缺失 result 不能伪装成空目录。
|
||||
func TestCrossPlatformCoverageParseDriveList_missingResultFailsClosed(t *testing.T) {
|
||||
if _, _, err := parseDriveList(`{"success":true}`); err == nil || !strings.Contains(err.Error(), "缺少 result") {
|
||||
t.Fatalf("parseDriveList error = %v, want missing-result failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
// push 遍历远端时返回体无法解析 → 解析错误如实上抛(与调用失败是不同分支)。
|
||||
func TestCrossPlatformCoverageDrivePush_remoteListParseFailurePropagates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "a.txt"), "x")
|
||||
withNoopPut(t)
|
||||
|
||||
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, _ int) (string, error) {
|
||||
if tool != "list_files" {
|
||||
return `{"result":{},"success":true}`, nil
|
||||
}
|
||||
return `{"result":"not-a-list"}`, nil // 既不是 {items:[]} 也不是数组
|
||||
}}
|
||||
err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT")
|
||||
if err == nil || !strings.Contains(err.Error(), "list_files") {
|
||||
t.Fatalf("expected a list_files parse error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// forceCaseInsensitiveFS 在当前用例内把大小写探测钉成不敏感,使等价路径冲突分支
|
||||
// 在大小写敏感的 CI 文件系统(ext4)上同样可达。
|
||||
func forceCaseInsensitiveFS(t *testing.T) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &isCaseInsensitiveFS, func(string) bool { return true })
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 等价路径冲突:pull / sync 都必须全部记 failed 且一个都不落盘
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// 远端 A.txt 与 a.txt 在大小写不敏感目标上映射到同一本地文件 → 两项都 failed,
|
||||
// 绝不顺序覆盖导致静默丢文件。
|
||||
func TestCrossPlatformCoverageDrivePull_equivalentPathCollisionFailsBoth(t *testing.T) {
|
||||
forceCaseInsensitiveFS(t)
|
||||
root := t.TempDir()
|
||||
|
||||
caller := pullListingCaller(
|
||||
`{"name":"A.txt","type":"file","fileId":"UP","modifyTime":1},` +
|
||||
`{"name":"a.txt","type":"file","fileId":"LOW","modifyTime":2}`)
|
||||
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
||||
t.Error("collided entries must not be downloaded")
|
||||
return nil
|
||||
})
|
||||
|
||||
err := runDriveCmd(t, caller, "pull", "--local-folder", root, "--remote-folder", "ROOT")
|
||||
var pf *drivePartialFailure
|
||||
if !errors.As(err, &pf) {
|
||||
t.Fatalf("expected drivePartialFailure, got %T %v", err, err)
|
||||
}
|
||||
for _, name := range []string{"A.txt", "a.txt"} {
|
||||
if _, statErr := os.Stat(filepath.Join(root, name)); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s must not be written on collision", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sync 的 new_remote 阶段同样拒绝等价冲突项。
|
||||
func TestCrossPlatformCoverageDriveSync_equivalentPathCollisionFailsBoth(t *testing.T) {
|
||||
forceCaseInsensitiveFS(t)
|
||||
root := t.TempDir()
|
||||
withSyncTransport(t, "remote")
|
||||
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[` +
|
||||
`{"name":"A.txt","type":"file","fileId":"UP","modifyTime":1},` +
|
||||
`{"name":"a.txt","type":"file","fileId":"LOW","modifyTime":2}` +
|
||||
`],"nextToken":""}}`,
|
||||
})
|
||||
err := runDriveCmd(t, caller, "sync", "--local-folder", root, "--remote-folder", "ROOT", "--quick")
|
||||
var sf *driveSyncFailure
|
||||
if !errors.As(err, &sf) {
|
||||
t.Fatalf("expected driveSyncFailure, got %T %v", err, err)
|
||||
}
|
||||
if sf.failed != 2 {
|
||||
t.Errorf("failed = %d, want 2 (both collided entries)", sf.failed)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// detectCaseInsensitiveFS:探针名无大小写差异时回退平台默认
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDetectCaseInsensitiveFS_caselessProbeFallsBackToPlatformDefault(t *testing.T) {
|
||||
// 纯数字模板:ToUpper 与原名相同,无法据此判定。
|
||||
testseam.Swap(t, &caseProbePattern, "1234567890*")
|
||||
|
||||
dir := t.TempDir()
|
||||
got := detectCaseInsensitiveFS(dir)
|
||||
// 回退值即平台默认;这里只断言它与平台默认一致,不写死具体布尔。
|
||||
want := runtime.GOOS == "windows" || runtime.GOOS == "darwin"
|
||||
if got != want {
|
||||
t.Errorf("detectCaseInsensitiveFS = %v, want platform default %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// isSafeRemoteSegmentPlatform:非 Windows 平台无额外约束
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageIsSafeRemoteSegmentPlatform_allowsPlainNames(t *testing.T) {
|
||||
for _, name := range []string{"a.txt", "报告.pdf", "with space.md"} {
|
||||
if !isSafeRemoteSegmentPlatform(name) {
|
||||
t.Errorf("isSafeRemoteSegmentPlatform(%q) = false, want true", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsSafeRemoteSegmentPlatform_rejectsNonCanonicalWindowsName(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
for _, name := range []string{
|
||||
`C:relative`, `a?.txt`, `a*.txt`, `a:stream`, `a<.txt`, `a>.txt`, `a|.txt`, `a".txt`,
|
||||
"name.", "name ", "...", "CON", "con.txt", "PRN", "AUX", "NUL", "COM1", "LPT9", "COM¹", "CONIN$",
|
||||
// 调用方 isSafeRemoteSegment 已先过滤分隔符,所以只有直接调用才会走到
|
||||
// filepath.Clean 改写这条防御分支;平台函数本身仍必须 fail closed。
|
||||
`a/b`, `.\a`,
|
||||
} {
|
||||
if isSafeRemoteSegmentPlatform(name) {
|
||||
t.Errorf("Windows unsafe segment %q must be rejected", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"report.txt", "报告 2026.pdf", "COM10", "LPT0"} {
|
||||
if !isSafeRemoteSegmentPlatform(name) {
|
||||
t.Errorf("Windows ordinary segment %q must be accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// WalkDir 单条目处理:Info() 与 filepath.Rel 的失败分支
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// errDirEntry 是一个 Info() 必然报错的 fs.DirEntry。
|
||||
type errDirEntry struct{ dir bool }
|
||||
|
||||
func (e errDirEntry) Name() string { return "entry" }
|
||||
func (e errDirEntry) IsDir() bool { return e.dir }
|
||||
func (e errDirEntry) Type() fs.FileMode { return 0 }
|
||||
func (e errDirEntry) Info() (fs.FileInfo, error) { return nil, errTestInfo }
|
||||
|
||||
var errTestInfo = errors.New("simulated DirEntry.Info failure")
|
||||
|
||||
// irregularDirEntry 的 Info() 成功,但报告为非常规文件(如设备文件)。
|
||||
type irregularDirEntry struct{}
|
||||
|
||||
func (irregularDirEntry) Name() string { return "dev" }
|
||||
func (irregularDirEntry) IsDir() bool { return false }
|
||||
func (irregularDirEntry) Type() fs.FileMode { return fs.ModeDevice }
|
||||
func (irregularDirEntry) Info() (fs.FileInfo, error) { return irregularFileInfo{}, nil }
|
||||
|
||||
type irregularFileInfo struct{}
|
||||
|
||||
func (irregularFileInfo) Name() string { return "dev" }
|
||||
func (irregularFileInfo) Size() int64 { return 0 }
|
||||
func (irregularFileInfo) Mode() fs.FileMode { return fs.ModeDevice }
|
||||
func (irregularFileInfo) ModTime() time.Time { return time.Unix(0, 0) }
|
||||
func (irregularFileInfo) IsDir() bool { return false }
|
||||
func (irregularFileInfo) Sys() any { return nil }
|
||||
|
||||
func TestCrossPlatformCoverageWalkLocalTreeEntry_errorBranches(t *testing.T) {
|
||||
files := map[string]*localFile{}
|
||||
|
||||
// WalkDir 自身报错原样上抛。
|
||||
if err := walkLocalTreeEntry("/root", "/root/a", errDirEntry{}, errTestInfo, files); !errors.Is(err, errTestInfo) {
|
||||
t.Errorf("incoming error must propagate, got %v", err)
|
||||
}
|
||||
// 目录条目直接跳过。
|
||||
if err := walkLocalTreeEntry("/root", "/root/sub", errDirEntry{dir: true}, nil, files); err != nil {
|
||||
t.Errorf("directory entry must be skipped, got %v", err)
|
||||
}
|
||||
// Info() 失败上抛。
|
||||
if err := walkLocalTreeEntry("/root", "/root/a", errDirEntry{}, nil, files); !errors.Is(err, errTestInfo) {
|
||||
t.Errorf("Info() error must propagate, got %v", err)
|
||||
}
|
||||
// 非常规文件忽略。
|
||||
if err := walkLocalTreeEntry("/root", "/root/dev", irregularDirEntry{}, nil, files); err != nil {
|
||||
t.Errorf("irregular entry must be skipped, got %v", err)
|
||||
}
|
||||
// filepath.Rel 失败(root 绝对、path 相对)上抛。
|
||||
if err := walkLocalTreeEntry("/root", "relative/path", regularDirEntry{}, nil, files); err == nil {
|
||||
t.Error("filepath.Rel error must propagate")
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("no entry should have been indexed, got %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
// regularDirEntry 的 Info() 返回一个常规文件,用于走到 filepath.Rel。
|
||||
type regularDirEntry struct{}
|
||||
|
||||
func (regularDirEntry) Name() string { return "a" }
|
||||
func (regularDirEntry) IsDir() bool { return false }
|
||||
func (regularDirEntry) Type() fs.FileMode { return 0 }
|
||||
func (regularDirEntry) Info() (fs.FileInfo, error) { return regularFileInfo{}, nil }
|
||||
|
||||
type regularFileInfo struct{}
|
||||
|
||||
func (regularFileInfo) Name() string { return "a" }
|
||||
func (regularFileInfo) Size() int64 { return 1 }
|
||||
func (regularFileInfo) Mode() fs.FileMode { return 0o644 }
|
||||
func (regularFileInfo) ModTime() time.Time { return time.Unix(1, 0) }
|
||||
func (regularFileInfo) IsDir() bool { return false }
|
||||
func (regularFileInfo) Sys() any { return nil }
|
||||
|
||||
func TestCrossPlatformCoverageWalkLocalForPushEntry_errorBranches(t *testing.T) {
|
||||
var dirs []string
|
||||
var files []localPushFile
|
||||
|
||||
// WalkDir 自身报错原样上抛。
|
||||
if err := walkLocalForPushEntry("/root", "/root/a", regularDirEntry{}, errTestInfo, &dirs, &files); !errors.Is(err, errTestInfo) {
|
||||
t.Errorf("incoming error must propagate, got %v", err)
|
||||
}
|
||||
// filepath.Rel 失败上抛。
|
||||
if err := walkLocalForPushEntry("/root", "relative/path", regularDirEntry{}, nil, &dirs, &files); err == nil {
|
||||
t.Error("filepath.Rel error must propagate")
|
||||
}
|
||||
// 根目录本身跳过。
|
||||
if err := walkLocalForPushEntry("/root", "/root", regularDirEntry{}, nil, &dirs, &files); err != nil {
|
||||
t.Errorf("root itself must be skipped, got %v", err)
|
||||
}
|
||||
// Info() 失败上抛。
|
||||
if err := walkLocalForPushEntry("/root", "/root/a", errDirEntry{}, nil, &dirs, &files); !errors.Is(err, errTestInfo) {
|
||||
t.Errorf("Info() error must propagate, got %v", err)
|
||||
}
|
||||
// 非常规文件忽略。
|
||||
if err := walkLocalForPushEntry("/root", "/root/dev", irregularDirEntry{}, nil, &dirs, &files); err != nil {
|
||||
t.Errorf("irregular entry must be skipped, got %v", err)
|
||||
}
|
||||
// 目录条目进 dirs。
|
||||
if err := walkLocalForPushEntry("/root", "/root/sub", errDirEntry{dir: true}, nil, &dirs, &files); err != nil {
|
||||
t.Errorf("directory entry must be recorded, got %v", err)
|
||||
}
|
||||
if len(dirs) != 1 || dirs[0] != "sub" {
|
||||
t.Errorf("dirs = %v, want [sub]", dirs)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("files = %v, want empty", files)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// errTestDownload 用于模拟 httpGetFile 下载失败。
|
||||
var errTestDownload = errors.New("simulated download failure")
|
||||
|
||||
// swapPullDownloadPath 为存量测试保留基于路径的传输模拟写法。
|
||||
// 生产 pull/sync 始终把已由 os.Root 安全打开的句柄交给 pullDownloadFile;
|
||||
// 只有测试适配层才把该句柄的名称传给旧 callback。
|
||||
func swapPullDownloadPath(t *testing.T, fn func(context.Context, string, map[string]string, string) error) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &pullDownloadFile, func(ctx context.Context, url string, headers map[string]string, destination *os.File) error {
|
||||
return fn(ctx, url, headers, destination.Name())
|
||||
})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// driveSyncFailure — sync 部分失败错误:exit=1
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncFailure(t *testing.T) {
|
||||
e := &driveSyncFailure{failed: 2}
|
||||
if e.ExitCode() != 1 {
|
||||
t.Errorf("ExitCode() = %d, want 1", e.ExitCode())
|
||||
}
|
||||
if e.Error() != e.RawStderr() {
|
||||
t.Error("Error() 与 RawStderr() 应一致")
|
||||
}
|
||||
if !strings.Contains(e.Error(), "2") {
|
||||
t.Errorf("Error() 应包含失败数, got %q", e.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// driveSyncSuffixedRel — keep-both 本地重命名目标生成
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncSuffixedRel(t *testing.T) {
|
||||
// 基本:扩展名前插入基于 fileId 末 8 位的后缀。
|
||||
got := driveSyncSuffixedRel("a/b.txt", "0123456789ABCDEF", map[string]bool{})
|
||||
if want := "a/b.conflict-89ABCDEF.txt"; got != want {
|
||||
t.Errorf("suffixed = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// 根目录下、无扩展名。
|
||||
if got := driveSyncSuffixedRel("readme", "", map[string]bool{}); got != "readme.conflict" {
|
||||
t.Errorf("suffixed = %q, want readme.conflict", got)
|
||||
}
|
||||
|
||||
// 首选目标已被占用时追加序号,直到不冲突。
|
||||
occupied := map[string]bool{
|
||||
"x.conflict-FID.log": true,
|
||||
"x.conflict-FID.0.log": true,
|
||||
}
|
||||
got = driveSyncSuffixedRel("x.log", "FID", occupied)
|
||||
if want := "x.conflict-FID.1.log"; got != want {
|
||||
t.Errorf("suffixed with collisions = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// sync 双向流程 — new_local 上传 / new_remote 下载
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// runDriveSyncTest 用 mock caller 执行 `drive sync`,注入 opened-file PUT / GET 为
|
||||
// 可控 no-op,避免真实 OSS 传输。返回 root.Execute() 的结果。
|
||||
func runDriveSyncTest(t *testing.T, caller *driveSyncMockCaller, localDir string, args ...string) error {
|
||||
// 默认下载:把远端内容写到目标路径,模拟成功落盘。
|
||||
return runDriveSyncTestWithGet(t, caller, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
||||
return os.WriteFile(dest, []byte("remote-content"), 0o644)
|
||||
}, localDir, args...)
|
||||
}
|
||||
|
||||
// runDriveSyncTestWithGet 同上,但允许自定义 httpGetFile 行为(例如模拟下载失败)。
|
||||
func runDriveSyncTestWithGet(t *testing.T, caller *driveSyncMockCaller, getFn func(context.Context, string, map[string]string, string) error, localDir string, args ...string) error {
|
||||
t.Helper()
|
||||
|
||||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil })
|
||||
swapPullDownloadPath(t, getFn)
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.AddCommand(newDriveCommand())
|
||||
|
||||
// sync 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。
|
||||
full := append([]string{"sync", "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"}, args...)
|
||||
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...))
|
||||
root.SetArgs(append([]string{"drive"}, full...))
|
||||
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
// 双向:本地独有 local.txt 应上传;远端独有 remote.txt 应下载到本地。
|
||||
func TestCrossPlatformCoverageDriveSync_bidirectional(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "local-only")
|
||||
|
||||
caller := &driveSyncMockCaller{
|
||||
// 远端仅有 remote.txt(本地不存在)→ new_remote,应被下载。
|
||||
listJSON: `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R_FID","md5":"z","modifyTime":2000}],"nextToken":""}}`,
|
||||
}
|
||||
if err := runDriveSyncTest(t, caller, dir); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// new_local:local.txt 走 get_upload_info(新建上传,传 parentId=ROOT、不传 overwriteFileId)。
|
||||
up := caller.callsFor("get_upload_info")
|
||||
if len(up) != 1 {
|
||||
t.Fatalf("expected 1 get_upload_info call, got %d", len(up))
|
||||
}
|
||||
if got := up[0].args["parentId"]; got != "ROOT" {
|
||||
t.Errorf("get_upload_info parentId = %v, want ROOT", got)
|
||||
}
|
||||
if _, ok := up[0].args["overwriteFileId"]; ok {
|
||||
t.Error("新建上传不应传 overwriteFileId")
|
||||
}
|
||||
|
||||
// new_remote:remote.txt 走 download_file,并已落盘到本地。
|
||||
dl := caller.callsFor("download_file")
|
||||
if len(dl) != 1 || dl[0].args["fileId"] != "R_FID" {
|
||||
t.Fatalf("expected 1 download_file(fileId=R_FID), got %v", dl)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "remote.txt")); err != nil {
|
||||
t.Errorf("remote.txt 应被下载到本地: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// modified + remote-wins:quick 模式下两侧 mtime 不同判为 modified,并显式拉取远端覆盖本地。
|
||||
func TestCrossPlatformCoverageDriveSync_modifiedRemoteWins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-old")
|
||||
// 本地 mtime 设为与远端不同,quick 模式判为 modified。
|
||||
local := readFileMTimeMillis(t, p)
|
||||
|
||||
caller := &driveSyncMockCaller{
|
||||
listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
}
|
||||
if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "remote-wins"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// remote-wins → 下载覆盖本地,且不应发生上传。
|
||||
if dl := caller.callsFor("download_file"); len(dl) != 1 || dl[0].args["fileId"] != "F_FID" {
|
||||
t.Errorf("expected download_file(fileId=F_FID), got %v", dl)
|
||||
}
|
||||
if up := caller.callsFor("get_upload_info"); len(up) != 0 {
|
||||
t.Errorf("remote-wins 不应上传, got %d get_upload_info calls", len(up))
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "remote-content" {
|
||||
t.Errorf("本地文件应被远端内容覆盖, got %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// modified + local-wins:改用 local-wins 时应覆盖上传远端(传 overwriteFileId、不传 parentId)。
|
||||
func TestCrossPlatformCoverageDriveSync_modifiedLocalWins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-new")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
|
||||
caller := &driveSyncMockCaller{
|
||||
listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
}
|
||||
if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "local-wins"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
up := caller.callsFor("get_upload_info")
|
||||
if len(up) != 1 {
|
||||
t.Fatalf("expected 1 get_upload_info call, got %d", len(up))
|
||||
}
|
||||
if got := up[0].args["overwriteFileId"]; got != "F_FID" {
|
||||
t.Errorf("local-wins 覆盖上传 overwriteFileId = %v, want F_FID", got)
|
||||
}
|
||||
if _, ok := up[0].args["parentId"]; ok {
|
||||
t.Error("覆盖上传不应传 parentId")
|
||||
}
|
||||
if dl := caller.callsFor("download_file"); len(dl) != 0 {
|
||||
t.Errorf("local-wins 不应下载, got %d download_file calls", len(dl))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncRejectsUnknownCommitEffect(t *testing.T) {
|
||||
for _, response := range []string{"", `{}`} {
|
||||
t.Run(response, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "payload")
|
||||
caller := &driveSyncMockCaller{
|
||||
listJSON: `{"result":{"items":[]}}`,
|
||||
commitJSON: &response,
|
||||
}
|
||||
err, output, putCalls := runDriveMirrorUploadCommandCapture(t, "sync", caller, dir)
|
||||
if err == nil {
|
||||
t.Fatal("empty commit result must make sync exit non-zero")
|
||||
}
|
||||
if failure, ok := err.(*driveSyncFailure); !ok || failure.ExitCode() != 1 {
|
||||
t.Fatalf("error=%T %v", err, err)
|
||||
}
|
||||
assertMirrorUploadFailedJSON(t, output)
|
||||
if putCalls != 1 {
|
||||
t.Fatalf("OSS PUT calls=%d, want exactly 1", putCalls)
|
||||
}
|
||||
if got := len(caller.callsFor("get_upload_info")); got != 1 {
|
||||
t.Fatalf("get_upload_info calls=%d, want 1", got)
|
||||
}
|
||||
if got := len(caller.callsFor("commit_upload")); got != 1 {
|
||||
t.Fatalf("commit_upload calls=%d, want 1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --dry-run:只计算差异,不触发任何 MCP 写操作与本地落盘。
|
||||
func TestCrossPlatformCoverageDriveSync_dryRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "x")
|
||||
|
||||
caller := &driveSyncMockCaller{
|
||||
listJSON: `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R_FID","modifyTime":2000}],"nextToken":""}}`,
|
||||
dryRun: true,
|
||||
}
|
||||
if err := runDriveSyncTest(t, caller, dir); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 {
|
||||
t.Errorf("dry-run 不应触发下载/上传, got %d 次写操作调用", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "remote.txt")); !os.IsNotExist(err) {
|
||||
t.Error("dry-run 不应把远端文件落盘到本地")
|
||||
}
|
||||
}
|
||||
|
||||
// remote-wins 下载失败:本地原文件必须原样保留(pullOneFile 先写临时文件、成功才原子
|
||||
// rename 覆盖),且命令以非零退出码退出。
|
||||
func TestCrossPlatformCoverageDriveSync_pullFailureKeepsLocal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-original")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
|
||||
caller := &driveSyncMockCaller{
|
||||
listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
}
|
||||
failGet := func(context.Context, string, map[string]string, string) error {
|
||||
return errTestDownload
|
||||
}
|
||||
err := runDriveSyncTestWithGet(t, caller, failGet, dir, "--quick", "--on-conflict", "remote-wins")
|
||||
if err == nil {
|
||||
t.Fatal("下载失败时应以非零退出码退出")
|
||||
}
|
||||
if ec, ok := err.(*driveSyncFailure); !ok || ec.ExitCode() != 1 {
|
||||
t.Fatalf("期望 driveSyncFailure(exit=1), got %T %v", err, err)
|
||||
}
|
||||
// 关键:原文件内容与存在性都不被破坏。
|
||||
if b, rerr := os.ReadFile(p); rerr != nil || string(b) != "local-original" {
|
||||
t.Errorf("下载失败后本地原文件应原样保留, got %q err=%v", string(b), rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// keep-both 成功路径:先以 no-clobber 硬链接保留本地版本,再把远端拉取到原路径。
|
||||
func TestCrossPlatformCoverageDriveSync_keepBothSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-new")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
|
||||
caller := &driveSyncMockCaller{
|
||||
listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
}
|
||||
if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "keep-both"); err != nil {
|
||||
t.Fatalf("keep-both 成功路径不应报错: %v", err)
|
||||
}
|
||||
|
||||
// 原路径 f.txt 应为远端内容(拉取落盘)。
|
||||
if b, _ := os.ReadFile(p); string(b) != "remote-content" {
|
||||
t.Errorf("f.txt 应为远端内容, got %q", string(b))
|
||||
}
|
||||
// 本地版本改名保留为 f.conflict-F_FID.txt,内容不变。
|
||||
aside := filepath.Join(dir, "f.conflict-F_FID.txt")
|
||||
if b, err := os.ReadFile(aside); err != nil || string(b) != "local-new" {
|
||||
t.Errorf("本地版本应改名保留为 %s 且内容不变, got %q err=%v", filepath.Base(aside), string(b), err)
|
||||
}
|
||||
// 下载发生一次(拉取远端到原路径),不应有上传。
|
||||
if dl := caller.callsFor("download_file"); len(dl) != 1 {
|
||||
t.Errorf("keep-both 应拉取远端一次, got %d download_file calls", len(dl))
|
||||
}
|
||||
if up := caller.callsFor("get_upload_info"); len(up) != 0 {
|
||||
t.Errorf("keep-both 不应上传, got %d get_upload_info calls", len(up))
|
||||
}
|
||||
}
|
||||
|
||||
// keep-both 下载失败:不做可能误删并发文件的回滚,原名与本地保留候选都应保留。
|
||||
func TestCrossPlatformCoverageDriveSync_keepBothPreservesLinkOnPullFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-original")
|
||||
local := readFileMTimeMillis(t, p)
|
||||
|
||||
caller := &driveSyncMockCaller{
|
||||
listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` +
|
||||
differentMillis(local) + `}],"nextToken":""}}`,
|
||||
}
|
||||
failGet := func(context.Context, string, map[string]string, string) error {
|
||||
return errTestDownload
|
||||
}
|
||||
err := runDriveSyncTestWithGet(t, caller, failGet, dir, "--quick", "--on-conflict", "keep-both")
|
||||
if err == nil {
|
||||
t.Fatal("下载失败时应以非零退出码退出")
|
||||
}
|
||||
// 原名尚未被远端发布,保持原内容。
|
||||
if b, rerr := os.ReadFile(p); rerr != nil || string(b) != "local-original" {
|
||||
t.Errorf("keep-both 拉取失败后原文件应保留: got %q err=%v", string(b), rerr)
|
||||
}
|
||||
aside := filepath.Join(dir, "f.conflict-F_FID.txt")
|
||||
if b, rerr := os.ReadFile(aside); rerr != nil || string(b) != "local-original" {
|
||||
t.Errorf("keep-both 拉取失败后候选应保留: got %q err=%v", string(b), rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// reserveSyncKeepBothTarget 不得占用「精确同名」的既有文件(occupied 未记录时),
|
||||
// 应原子占位到下一个后缀,且既有文件内容不被破坏。此断言在任何文件系统上都成立。
|
||||
func TestCrossPlatformCoverageReserveSyncKeepBothTarget_noOverwriteExact(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "f.txt"), "local-data")
|
||||
// 预置一个与首选候选精确同名的既有文件(模拟未被 occupied 记录的外部残留)。
|
||||
first := "f.conflict-ABCD1234.txt"
|
||||
mustWrite(t, filepath.Join(dir, first), "existing-data")
|
||||
|
||||
rel, abs, err := reserveSyncKeepBothTarget(dir, "f.txt", "0000ABCD1234", map[string]bool{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel == first {
|
||||
t.Fatalf("不应占用既有文件 %s", first)
|
||||
}
|
||||
if want := "f.conflict-ABCD1234.1.txt"; rel != want {
|
||||
t.Errorf("rel = %q, want %q", rel, want)
|
||||
}
|
||||
// 既有文件内容原样保留(未被覆盖)。
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, first)); string(b) != "existing-data" {
|
||||
t.Error("既有文件被覆盖,数据丢失")
|
||||
}
|
||||
// 返回的目标是原始本地版本的硬链接,不是空占位。
|
||||
if b, e := os.ReadFile(abs); e != nil || string(b) != "local-data" {
|
||||
t.Errorf("候选硬链接内容错误: content=%q err=%v", b, e)
|
||||
}
|
||||
}
|
||||
|
||||
// reserveSyncKeepBothTarget 在大小写不敏感文件系统上,不得覆盖与候选「大小写等价」的
|
||||
// 既有文件——O_EXCL 由 OS 兜底判等价性,occupied 的精确字符串键覆盖不到这种情况。
|
||||
func TestCrossPlatformCoverageReserveSyncKeepBothTarget_noOverwriteCaseEquivalent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "f.txt"), "local-data")
|
||||
if !isCaseInsensitiveFS(dir) {
|
||||
t.Skip("文件系统大小写敏感,跳过大小写等价覆盖测试")
|
||||
}
|
||||
// 既有文件用大写变体,occupied 为空(模拟精确键查重覆盖不到的等价文件)。
|
||||
existing := "f.CONFLICT-ABCD1234.TXT"
|
||||
mustWrite(t, filepath.Join(dir, existing), "case-variant-data")
|
||||
|
||||
rel, _, err := reserveSyncKeepBothTarget(dir, "f.txt", "0000abcd1234", map[string]bool{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 生成的小写候选与既有大写文件在不敏感 FS 上等价,本不应被占用。
|
||||
if strings.EqualFold(rel, "f.conflict-abcd1234.txt") {
|
||||
t.Errorf("首选候选与既有文件等价,不应被占用: %q", rel)
|
||||
}
|
||||
// 既有文件内容原样保留。
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, existing)); string(b) != "case-variant-data" {
|
||||
t.Error("大小写等价的既有文件被覆盖,数据丢失")
|
||||
}
|
||||
}
|
||||
|
||||
// 非交互 ask(stdin 立即 EOF):冲突项应被跳过而非中止整个同步,
|
||||
// new_local 仍上传、new_remote 仍下载,且被跳过的 modified 本地文件不被改动。
|
||||
func TestCrossPlatformCoverageDriveSync_askNonInteractiveSkipsConflict(t *testing.T) {
|
||||
testseam.Swap(t, &syncAskStdin, io.Reader(strings.NewReader(""))) // 非交互:立即 EOF
|
||||
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "local-only") // new_local → 应上传
|
||||
p := filepath.Join(dir, "f.txt")
|
||||
mustWrite(t, p, "local-old") // modified → ask 非交互跳过
|
||||
local := readFileMTimeMillis(t, p)
|
||||
|
||||
caller := &driveSyncMockCaller{
|
||||
listJSON: `{"result":{"items":[` +
|
||||
`{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + differentMillis(local) + `},` +
|
||||
`{"name":"remote.txt","type":"file","fileId":"R_FID","modifyTime":2000}` +
|
||||
`],"nextToken":""}}`,
|
||||
}
|
||||
if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "ask"); err != nil {
|
||||
t.Fatalf("非交互 ask 不应中止同步: %v", err)
|
||||
}
|
||||
|
||||
// new_local 上传、new_remote 下载都应照常发生。
|
||||
if up := caller.callsFor("get_upload_info"); len(up) != 1 {
|
||||
t.Errorf("new_local 应被上传, got %d get_upload_info calls", len(up))
|
||||
}
|
||||
if dl := caller.callsFor("download_file"); len(dl) != 1 || dl[0].args["fileId"] != "R_FID" {
|
||||
t.Errorf("new_remote 应被下载, got %v", dl)
|
||||
}
|
||||
// modified 冲突被跳过:本地 f.txt 未被覆盖。
|
||||
if b, _ := os.ReadFile(p); string(b) != "local-old" {
|
||||
t.Errorf("ask 跳过的 modified 文件不应被改动, got %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// readFileMTimeMillis 返回文件的 mtime(Unix 毫秒)。
|
||||
func readFileMTimeMillis(t *testing.T, path string) int64 {
|
||||
t.Helper()
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fi.ModTime().UnixMilli()
|
||||
}
|
||||
|
||||
// differentMillis 返回一个与 base 不同的毫秒数字符串,保证 quick 模式判为 modified。
|
||||
func differentMillis(base int64) string {
|
||||
return strconv.FormatInt(base+60000, 10) // 差 1 分钟,足以区分
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user