diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2ccfa40..e288095 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,9 +1,11 @@
# 参与贡献
-欢迎提交 Issue 和 Pull Request。开始修改前,请先搜索已有 Issue;较大的协议或行为变更建议
-先开 Issue 说明使用场景和安全影响。
+欢迎提交 Issue 和 Pull Request。协议、认证、数据写入或命令行为的较大变更,建议先在 Issue
+中说明使用场景、兼容性和安全影响。
-开发环境需要 Go 1.22、GNU Make 和可用于 race detector 的 C 编译工具链。提交前运行:
+## 开发与检查
+
+开发环境需要 Go 1.22、GNU Make 和支持 race detector 的 C 编译工具链。提交前运行:
```bash
make test
@@ -11,9 +13,16 @@ CGO_ENABLED=0 go test ./...
git diff --check
```
-贡献应包含与行为变化对应的测试,并保持错误输出不泄露秘密。测试 fixture 必须使用虚构的
-账户、对象 ID、token、URL、证书和路径;不要提交真实凭据、浏览器数据、OAuth 回调、签名
-对象 URL或个人文件名。Release 中的系统 SPKI pin 是经过独立核验后公开分发的信任锚;普通
-测试 fixture 不应复制生产证书或 pin。
+行为变更应配套单元或协议测试,并同步更新 README 或 `docs/`。提交只包含本次修改需要的
+文件,保持 commit 信息清晰。
+
+## 测试数据与秘密
+
+测试 fixture 使用虚构的账户、对象 ID、token、共享链接、证书和路径。请勿提交真实凭据、
+浏览器数据、OAuth 回调 URL、Cookie、link token、签名对象 URL 或个人文件名。错误信息与
+测试日志也不能包含这些内容。
+
+Release 中的系统 SPKI pin 是通过独立渠道核验后分发的公开信任锚;普通测试 fixture 使用
+本地测试证书和测试 pin。
提交即表示贡献内容按本仓库的 [MIT License](LICENSE) 授权。
diff --git a/Makefile b/Makefile
index 8196dd1..c23066a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
SHELL := /bin/sh
ifeq ($(origin VERSION),undefined)
-override VERSION := 0.2.0
+override VERSION := 0.3.0
else
override VERSION := $(value VERSION)
endif
diff --git a/README.md b/README.md
index 16cd52f..794bfe3 100644
--- a/README.md
+++ b/README.md
@@ -1,35 +1,35 @@
# 北大网盘轻量 CLI
-`pku-drive` 是一个面向 Linux 服务器和 SSH 终端的非官方北大网盘命令行工具,支持浏览器
-登录、列目录、创建目录、上传、下载、删除和注销。发布包是 Linux amd64 静态单文件;每个
-服务器用户使用各自的北大账号和凭据。
+`pku-drive` 是面向 Linux 服务器和 SSH 终端的北大网盘命令行工具。它支持浏览器登录、
+目录浏览、文件上传与下载、创建目录、删除,以及直接下载他人分享的文件。发布包是 Linux
+amd64 静态单文件;系统中的每位用户使用自己的北大账号和凭据。
## 安装
-Debian / Ubuntu 推荐安装 `.deb`:
+### Debian / Ubuntu
```bash
-curl -LO https://github.com/Findddx/pku-drive-cli/releases/download/v0.2.0/pku-drive-cli_0.2.0-1_amd64.deb
-curl -LO https://github.com/Findddx/pku-drive-cli/releases/download/v0.2.0/SHA256SUMS
-grep ' pku-drive-cli_0.2.0-1_amd64.deb$' SHA256SUMS | sha256sum -c -
-sudo apt install ./pku-drive-cli_0.2.0-1_amd64.deb
+curl -LO https://github.com/Findddx/pku-drive-cli/releases/download/v0.3.0/pku-drive-cli_0.3.0-1_amd64.deb
+curl -LO https://github.com/Findddx/pku-drive-cli/releases/download/v0.3.0/SHA256SUMS
+grep ' pku-drive-cli_0.3.0-1_amd64.deb$' SHA256SUMS | sha256sum -c -
+sudo apt install ./pku-drive-cli_0.3.0-1_amd64.deb
pku-drive version
```
-其他 Linux amd64 发行版可安装 tar 包:
+### 其他 Linux amd64 发行版
```bash
-curl -LO https://github.com/Findddx/pku-drive-cli/releases/download/v0.2.0/pku-drive-cli-0.2.0-linux-amd64.tar.gz
-curl -LO https://github.com/Findddx/pku-drive-cli/releases/download/v0.2.0/SHA256SUMS
-grep ' pku-drive-cli-0.2.0-linux-amd64.tar.gz$' SHA256SUMS | sha256sum -c -
-sudo tar -xzf pku-drive-cli-0.2.0-linux-amd64.tar.gz --strip-components=1 -C /
+curl -LO https://github.com/Findddx/pku-drive-cli/releases/download/v0.3.0/pku-drive-cli-0.3.0-linux-amd64.tar.gz
+curl -LO https://github.com/Findddx/pku-drive-cli/releases/download/v0.3.0/SHA256SUMS
+grep ' pku-drive-cli-0.3.0-linux-amd64.tar.gz$' SHA256SUMS | sha256sum -c -
+sudo tar -xzf pku-drive-cli-0.3.0-linux-amd64.tar.gz --strip-components=1 -C /
/usr/local/bin/pku-drive version
```
-不要同时安装两种包。系统安装后所有 Linux 用户都能运行程序,但每个用户必须以自己的
-普通账号分别登录;日常网盘命令不要加 `sudo`。
+同一台机器选择一种系统安装方式即可。安装后所有用户都能运行 `pku-drive`,每位用户以
+自己的普通 Linux 账号登录;日常命令无需 `sudo`。
-从源码构建:
+### 从源码构建
```bash
git clone https://github.com/Findddx/pku-drive-cli.git
@@ -41,112 +41,160 @@ make build
## 登录
-浏览器运行在服务器、VNC 或 X11 环境时:
+服务器有桌面、VNC 或 X11 浏览器时:
```bash
pku-drive login
pku-drive status
```
-浏览器运行在 SSH 客户端电脑上时:
+通过 SSH 登录服务器、浏览器运行在本地电脑时:
```bash
pku-drive login --paste-callback
```
-打开终端打印的授权链接并完成统一身份认证。浏览器最后跳到
-`http://127.0.0.1:PORT/callback?...` 时,即使页面显示无法连接,也复制地址栏中的**完整
-URL**,粘贴到 SSH 终端的隐藏输入提示并回车。该 URL 含一次性认证信息,不要分享或保存。
+在本地浏览器打开终端显示的授权链接并完成统一身份认证。浏览器最后跳转到
+`http://127.0.0.1:PORT/callback?...` 后,复制地址栏中的**完整 URL**,粘贴回 SSH 终端并
+回车。页面显示无法连接不影响粘贴认证。回调 URL 含一次性认证信息,请勿分享、记录或写入
+命令历史。
+
+## 路径规则
+
+- 网盘路径是以 `/` 开头的绝对路径,例如 `'/个人文档/课题/data.csv'`。
+- 本地路径与普通 shell 命令相同,可以是相对路径或绝对路径。
+- 路径含空格、中文或 shell 特殊字符时,用单引号包围。
+- 分享链接内的路径相对于分享根目录,不以 `/` 开头,例如 `'数据/样本.csv'`。
+
+## 命令速查
+
+| 操作 | 命令 |
+|---|---|
+| 登录 | `pku-drive login [--paste-callback]` |
+| 查看登录状态 | `pku-drive status` |
+| 列出网盘目录 | `pku-drive ls [网盘目录]` |
+| 创建目录 | `pku-drive mkdir '网盘目录' [--parents]` |
+| 上传文件 | `pku-drive put 本地文件 '网盘文件或目录'` |
+| 下载文件 | `pku-drive get '网盘文件' 本地文件或目录` |
+| 浏览分享链接 | `pku-drive ls --share 分享链接 [分享内目录]` |
+| 下载分享文件 | `pku-drive get --share 分享链接 本地目录 [分享内文件...]` |
+| 删除 | `pku-drive rm '网盘文件或目录' [--recursive]` |
+| 注销 | `pku-drive logout` |
+| 查看版本 | `pku-drive version` |
## 常用操作
-远端路径使用以 `/` 开头的绝对路径;含空格或中文时用单引号包围。
-
-### 查看状态和目录
+### 查看目录
```bash
-pku-drive status
pku-drive ls /
-pku-drive ls '/个人文档/项目'
+pku-drive ls '/个人文档/课题'
```
-### 创建文件夹
+### 上传文件
+
+参数顺序是 `put 本地文件 '网盘文件或目录'`:
```bash
-# 父目录必须存在
-pku-drive mkdir '/个人文档/项目/数据'
+# 上传并指定网盘文件名
+pku-drive put ./report.csv '/个人文档/课题/report.csv'
+
+# 上传到已有网盘目录,保留文件名 report.csv
+pku-drive put ./report.csv '/个人文档/课题/'
+
+# 覆盖网盘中的同名文件
+pku-drive put ./report.csv '/个人文档/课题/report.csv' --overwrite
-# 同时创建缺少的各级父目录
-pku-drive mkdir '/个人文档/项目/数据/2026' --parents
+# 大文件上传;中断后重复同一命令可继续已校验的分片
+pku-drive put ./dataset.tar '/个人文档/课题/dataset.tar' --quiet
```
-### 上传
+### 下载自己的网盘文件
-参数顺序是:`put 本地文件 '网盘文件或目录'`。
+参数顺序是 `get '网盘文件' 本地文件或目录`:
```bash
-# 指定网盘文件名
-pku-drive put ./report.csv '/个人文档/项目/report.csv'
+# 下载并指定本地文件名
+pku-drive get '/个人文档/课题/report.csv' ./report.csv
-# 上传到已有网盘目录,保留本地文件名 report.csv
-pku-drive put ./report.csv '/个人文档/项目/'
+# 下载到已有目录,保留文件名 report.csv
+pku-drive get '/个人文档/课题/report.csv' ./downloads/
-# 覆盖已有同名文件
-pku-drive put ./report.csv '/个人文档/项目/report.csv' --overwrite
-
-# 不显示传输进度;上传中断后重复同一条命令即可续传
-pku-drive put ./large.bin '/个人文档/项目/large.bin' --quiet
+# 覆盖本地同名文件
+pku-drive get '/个人文档/课题/report.csv' ./report.csv --overwrite
```
-默认不覆盖已有文件;只有显式使用 `--overwrite` 才会覆盖。
-
-### 下载
-
-参数顺序是:`get '网盘文件' 本地文件或已有目录`。
+### 创建与删除目录
```bash
-# 指定本地文件名
-pku-drive get '/个人文档/项目/report.csv' ./report.csv
+# 父目录已经存在
+pku-drive mkdir '/个人文档/课题/数据'
-# 下载到已有本地目录,保留网盘文件名 report.csv
-pku-drive get '/个人文档/项目/report.csv' ./downloads/
+# 同时创建缺少的父目录
+pku-drive mkdir '/个人文档/课题/数据/2026' --parents
-# 覆盖已有本地文件
-pku-drive get '/个人文档/项目/report.csv' ./report.csv --overwrite
+# 删除文件,通常移入网盘回收站
+pku-drive rm '/个人文档/课题/report.csv'
-# 不显示传输进度
-pku-drive get '/个人文档/项目/large.bin' ./large.bin --quiet
+# 删除目录及其中内容
+pku-drive rm '/个人文档/课题/旧数据' --recursive
```
-下载先写入同目录临时文件,完整性核验成功后再原子放到目标位置;默认不覆盖本地文件。
+## 下载分享链接
+
+公开分享(anonymous)可直接浏览和下载。组织范围分享(realname)先运行 `pku-drive login`
+或 `pku-drive login --paste-callback`,工具会使用当前用户身份检查访问权限。
-### 删除
+交互式选择:
```bash
-# 删除文件:移入网盘回收站
-pku-drive rm '/个人文档/项目/report.csv'
+mkdir -p ./downloads
+pku-drive get --share 'https://disk.pku.edu.cn/link/...' ./downloads/
+```
+
+本地目标必须是已存在的目录。单文件分享会直接下载;文件夹分享每次显示当前目录的一层
+内容,进入目录时再加载下一层,因此大型分享也能快速浏览。选择会在切换目录后保留,下载
+时维持分享中的相对目录结构。批量下载按文件逐个校验和落盘;若中途失败,终端会报告已完成
+数量,已完成文件保留在目标目录。重试时可只选择未完成文件,或确认覆盖后加入 `--overwrite`。
+
+| 按键 | 操作 |
+|---|---|
+| `↑` / `↓` 或 `k` / `j` | 移动光标 |
+| `→` 或 `l` | 进入目录 |
+| `←`、`h` 或 `Backspace` | 返回上级目录 |
+| `Space` | 选中或取消当前文件 |
+| `Enter` | 确认并开始下载 |
+| `q` 或 `Esc` | 取消 |
+
+脚本和无交互终端先用 JSON 列目录,再按相对路径下载一个或多个文件:
-# 删除目录及其全部内容:必须显式加 --recursive
-pku-drive rm '/个人文档/项目/旧数据' --recursive
+```bash
+pku-drive ls --share 'https://disk.pku.edu.cn/link/...' --json
+pku-drive ls --share 'https://disk.pku.edu.cn/link/...' '数据' --json
+pku-drive get --share 'https://disk.pku.edu.cn/link/...' ./downloads/ \
+ 'README.pdf' '数据/样本.csv' --json
```
-删除通常会移入网盘回收站;若服务器启用了删除审核,命令会显示已提交审核。`/` 和顶级
-文档库不能删除。
+当前版本处理无需提取码和手机验证的分享链接。相关错误处理见
+[故障排查](docs/troubleshooting.md#分享链接)。
+
+## JSON 输出
-### JSON、注销和版本
+在命令末尾加入 `--json` 可获得结构化结果:
```bash
-pku-drive ls '/个人文档/项目' --json
pku-drive status --json
-pku-drive logout
-pku-drive version
+pku-drive ls '/个人文档/课题' --json
+pku-drive put ./report.csv '/个人文档/课题/report.csv' --json
```
-## 更多文档
+脚本应同时检查进程退出码和 JSON 中的 `ok` 字段。诊断信息与登录授权链接输出到 stderr。
+
+## 文档
-- [完整参考与故障排查](docs/troubleshooting.md)
+- [故障排查与高级说明](docs/troubleshooting.md)
- [API 与兼容性说明](docs/api-notes.md)
- [安全策略](SECURITY.md)
- [参与贡献](CONTRIBUTING.md)
-本项目不是北京大学或爱数官方软件。
+本项目是社区维护的非官方工具,与北京大学及爱数无隶属关系。
diff --git a/cmd/pku-drive/wire.go b/cmd/pku-drive/wire.go
index 5db24be..ce7ac65 100644
--- a/cmd/pku-drive/wire.go
+++ b/cmd/pku-drive/wire.go
@@ -18,6 +18,7 @@ import (
"github.com/Findddx/pku-drive-cli/internal/httpx"
"github.com/Findddx/pku-drive-cli/internal/oauth"
"github.com/Findddx/pku-drive-cli/internal/remote"
+ "github.com/Findddx/pku-drive-cli/internal/sharelink"
"github.com/Findddx/pku-drive-cli/internal/upload"
)
@@ -59,12 +60,17 @@ type downloaderOperations interface {
Get(context.Context, anyshare.Item, string, bool, download.Progress) (download.Result, error)
}
+type shareOperations interface {
+ Open(context.Context, string) (*sharelink.Session, error)
+}
+
type dependencyServices struct {
manager authOperations
identity identityOperations
remote remoteOperations
uploader uploaderOperations
downloader downloaderOperations
+ shares shareOperations
}
func wireDependencies() cli.Dependencies {
@@ -109,8 +115,12 @@ func newWiredRuntime(options wireOptions) (*wiredRuntime, error) {
remoteService := remote.NewService(api)
uploader := upload.NewUploader(server, api, remoteService, upload.NewStateStore(storagePaths.UploadStateDir), 4)
downloader := download.NewDownloader(api)
+ shares, err := sharelink.NewOpener(server, httpClient, api)
+ if err != nil {
+ return nil, err
+ }
dependencies := dependenciesFromServices(dependencyServices{
- manager: manager, identity: api, remote: remoteService, uploader: uploader, downloader: downloader,
+ manager: manager, identity: api, remote: remoteService, uploader: uploader, downloader: downloader, shares: shares,
})
return &wiredRuntime{dependencies: dependencies, httpClient: httpClient, uploader: uploader}, nil
}
@@ -191,6 +201,16 @@ func dependenciesFromServices(services dependencyServices) cli.Dependencies {
}
return cli.GetResult{RemotePath: item.Path, RemoteID: result.RemoteID, Revision: result.Revision, LocalPath: result.LocalPath, Size: result.Size}, nil
},
+ OpenShare: func(ctx context.Context, rawLink string) (cli.ShareSession, error) {
+ if services.shares == nil {
+ return nil, apperr.Wrap(apperr.Local, "share link", "share dependency unavailable", errors.New("missing share dependency"))
+ }
+ session, err := services.shares.Open(ctx, rawLink)
+ if err != nil {
+ return nil, err
+ }
+ return &cliShareSession{session: session}, nil
+ },
Delete: func(ctx context.Context, remotePath string, recursive bool) (cli.DeleteResult, error) {
result, err := services.remote.Delete(ctx, remotePath, recursive)
if err != nil {
@@ -259,9 +279,57 @@ func unavailableDependencies(cause error) cli.Dependencies {
Get: func(ctx context.Context, _, _ string, _ bool, _ upload.Progress) (cli.GetResult, error) {
return cli.GetResult{}, failure(ctx, "get")
},
+ OpenShare: func(ctx context.Context, _ string) (cli.ShareSession, error) {
+ return nil, failure(ctx, "share link")
+ },
Delete: func(ctx context.Context, _ string, _ bool) (cli.DeleteResult, error) {
return cli.DeleteResult{}, failure(ctx, "rm")
},
Logout: func(ctx context.Context, _ bool) error { return failure(ctx, "logout") },
}
}
+
+type cliShareSession struct{ session *sharelink.Session }
+
+func (session *cliShareSession) Root() cli.ShareEntry {
+ if session == nil || session.session == nil {
+ return cli.ShareEntry{}
+ }
+ return shareEntryResult(session.session.Root())
+}
+
+func (session *cliShareSession) List(ctx context.Context, relativeDir string) ([]cli.ShareEntry, error) {
+ if session == nil || session.session == nil {
+ return nil, apperr.Wrap(apperr.Local, "share link", "share dependency unavailable", errors.New("missing share session"))
+ }
+ entries, err := session.session.List(ctx, relativeDir)
+ if err != nil {
+ return nil, err
+ }
+ results := make([]cli.ShareEntry, len(entries))
+ for index, entry := range entries {
+ results[index] = shareEntryResult(entry)
+ }
+ return results, nil
+}
+
+func (session *cliShareSession) Download(ctx context.Context, relativeFiles []string, localDir string, overwrite bool, progress upload.Progress) ([]cli.ShareGetResult, error) {
+ if session == nil || session.session == nil {
+ return nil, apperr.Wrap(apperr.Local, "share link", "share dependency unavailable", errors.New("missing share session"))
+ }
+ downloaded, err := session.session.Download(ctx, relativeFiles, localDir, overwrite, progress)
+ results := make([]cli.ShareGetResult, len(downloaded))
+ for index, item := range downloaded {
+ results[index] = cli.ShareGetResult{
+ SharePath: item.Entry.Path,
+ Revision: item.Result.Revision,
+ LocalPath: item.Result.LocalPath,
+ Size: item.Result.Size,
+ }
+ }
+ return results, err
+}
+
+func shareEntryResult(entry sharelink.Entry) cli.ShareEntry {
+ return cli.ShareEntry{Name: entry.Name, SharePath: entry.Path, Type: entry.Type, Size: entry.Size}
+}
diff --git a/docs/api-notes.md b/docs/api-notes.md
index 9b65ae1..9604857 100644
--- a/docs/api-notes.md
+++ b/docs/api-notes.md
@@ -1,126 +1,132 @@
# 北大网盘 API 与兼容性说明
-本文记录 `pku-drive` 实际实现依赖的公开协议。它不是抓包记录:没有使用已登录浏览器、
-Cookie、Local Storage 或真实会话数据,也不包含 token、client secret、Authorization
-值、签名对象 URL、完成 XML 或原始认证响应。
-
-安装和基本命令见[简明 README](../README.md),登录与故障处理见[完整参考](troubleshooting.md)。
-
-## 公开依据
-
-- [北京大学北大网盘服务说明](https://its.pku.edu.cn/service_1_webdisk.jsp):确认服务入口
- `https://disk.pku.edu.cn`、北京大学统一身份认证账户及 Linux 客户端。
-- [AnyShare OAuth 2.0](https://developers.aishutech.com/napi/documents/167):授权码、
- refresh token 和客户端认证流程。项目早期调查还记录了同站文档编号
- [240](https://developers.aishutech.com/napi/documents/240),当前实现以 `/167` 的公开流程
- 和本地协议测试为准。
-- [AnyShare 文档访问 API 7](https://developers.aishutech.com/napi/documents/307):文档库、
- 目录、秒传、对象上传、multipart、授权刷新及完成接口;旧版同类入口为
- [176](https://developers.aishutech.com/napi/documents/176)。
-- [北大实例公开客户端清单](https://disk.pku.edu.cn/api/deploy-manager/client/package):用于
- 确认公开发布的 Linux 客户端版本,不涉及认证。
-
-此外,仅把下载的
-`北大网盘2.0_All_Linux_x64-7.0.6.3-20250716-Terminator-281.deb` 当作数据做过静态检查,
-未安装或执行。公开文档、该静态检查和无秘密的本地 TLS fixture 共同用于确认字段差异;
-以下协议字段并非全部逐项在生产环境实测。
+本文面向维护者,记录 `pku-drive` 使用的 OAuth、AnyShare 文档与分享链接协议,以及客户端
+必须保持的验证边界。安装与命令见[项目 README](../README.md),运行故障见
+[故障排查](troubleshooting.md)。
+
+## 协议依据
+
+- [北京大学北大网盘服务说明](https://its.pku.edu.cn/service_1_webdisk.jsp):服务入口、统一
+ 身份认证和 Linux 客户端信息。
+- [AnyShare OAuth 2.0](https://developers.aishutech.com/napi/documents/167):授权码、refresh
+ token 和动态客户端流程。
+- [AnyShare 文档访问 API 7](https://developers.aishutech.com/napi/documents/307):文档库、目录、
+ 分享链接、上传、下载与授权刷新接口。
+- [北大实例公开客户端清单](https://disk.pku.edu.cn/api/deploy-manager/client/package):公开
+ Linux 客户端版本信息。
+
+兼容性判断还参考官方 Linux 客户端包的静态资源,以及不含真实账户数据的本地 TLS 协议
+fixture。测试数据使用虚构 token、对象 ID、URL 和证书。
## OAuth 控制面
-| 方法与路径 | 用途与实现形状 |
+| 方法与路径 | 用途 |
|---|---|
-| `POST /oauth2/clients` | 动态注册;JSON 包含客户端名、`authorization_code` / `refresh_token` grant、`code` response、`offline openid all` scope、loopback redirect URI 和 Linux CLI metadata。 |
-| `GET /oauth2/auth` | 浏览器授权;query 含 `client_id`、`redirect_uri`、`response_type=code`、scope、随机 `state`,默认再含 PKCE S256 challenge。 |
-| `POST /oauth2/token` | `application/x-www-form-urlencoded`;authorization-code exchange 或 refresh-token grant;动态客户端 ID/secret 使用 HTTP Basic。 |
-| `POST /oauth2/revoke` | form 中传待撤销 token,动态客户端使用 HTTP Basic;普通 logout 最多分别处理 refresh/access token。 |
+| `POST /oauth2/clients` | 动态注册 CLI;grant 包含 `authorization_code`、`refresh_token`,scope 为 `offline openid all`。 |
+| `GET /oauth2/auth` | 浏览器授权;携带 loopback `redirect_uri`、随机 `state` 和默认的 PKCE S256 challenge。 |
+| `POST /oauth2/token` | 交换授权码或刷新 access token;动态客户端凭据使用 HTTP Basic。 |
+| `POST /oauth2/revoke` | 注销时撤销 refresh token 与 access token。 |
-回调只绑定 `127.0.0.1`,严格匹配路径、`state` 和一次性 code。只有 token endpoint 明确返回
-PKCE 不受支持时才重试无 PKCE 流程,`state` 校验不降级。所有服务端 URL 必须是无
-userinfo、query、fragment 的绝对 HTTPS URL。
+回调监听绑定 `127.0.0.1`,并严格验证回调路径、`state` 和一次性 code。服务端明确拒绝 PKCE
+时才以相同安全状态重试兼容流程。配置中的服务端地址必须是没有 userinfo、query 和 fragment
+的绝对 HTTPS URL。
-## 文档与账户控制面
+凭据按用户保存在受权限约束的 XDG 配置目录。普通 API 请求携带 OAuth Bearer;首次收到
+`401` 时刷新 token 并重放一次。
-所有下列 AnyShare 请求使用 OAuth Bearer;收到一次 `401` 时刷新凭据并只重放一次。
+## 账户、目录与下载
| 方法与路径 | 请求或响应要点 |
|---|---|
-| `POST /api/eacp/v1/user/get` | 当前账户;兼容姓名字段 `name` / `username`。 |
-| `GET /api/efast/v1/entry-doc-lib?direction=asc&sort=doc_lib_name` | 可见顶级文档库。 |
-| `GET /api/efast/v1/folders/{escaped-id}/sub_objects?limit=1000[&marker=…]` | 目录分页;响应 `dirs`、`files`、`next_marker`。重复 marker 会失败。 |
-| `POST /api/efast/v1/file/getinfobypath` | JSON `{namepath}`,保留的适配器兼容接口;当前公共 CLI 路径解析通过入口与分页列举完成,没有生产调用点。 |
-| `POST /api/efast/v1/dir/create` | JSON `{docid,name,ondup:1}`;不自动改名。 |
-| `POST /api/efast/v1/file/metadata` | JSON `{docid,rev?}`,上传后按精确身份核验。 |
-| `POST /api/efast/v1/file/osdownload` | JSON `{docid,rev,savename,authtype:"QUERY_STRING",usehttps:true}`;为精确文件修订取得短时 HTTPS GET 签名。 |
-| `POST /api/efast/v1/file/delete` | JSON `{docid}`;按精确文件 ID 移入文档库回收站。HTTP 200 表示已删除,202 表示已进入管理员审核。 |
-| `POST /api/efast/v1/dir/delete` | JSON `{docid,check_upload_process:true}`;按精确目录 ID 删除整棵目录树,并要求服务端检查进行中的子上传。HTTP 200/202 语义同文件删除。 |
-
-对象响应兼容 `id` / `docid`;类型缺失时 `size=-1` 作为目录提示。修改时间兼容微秒整数
-`modified` 和 RFC3339 `modified_at`,后者存在时优先;`custom_metadata.client_mtime` 存在时
-优先于顶层 `client_mtime`。目录列表在客户端合并分页并稳定排序。
+| `POST /api/eacp/v1/user/get` | 当前账户;姓名兼容 `name` 与 `username`。 |
+| `GET /api/efast/v1/entry-doc-lib?direction=asc&sort=doc_lib_name` | 当前账号可见的顶级文档库。 |
+| `GET /api/efast/v1/folders/{id}/sub_objects?limit=1000[&marker=…]` | 目录分页;客户端合并 `dirs`、`files` 并检测重复 marker。 |
+| `POST /api/efast/v1/file/getinfobypath` | `{namepath}` 兼容接口;主要路径解析通过入口和逐级列举完成。 |
+| `POST /api/efast/v1/dir/create` | `{docid,name,ondup:1}`;父 ID 来自精确路径解析。 |
+| `POST /api/efast/v1/file/metadata` | `{docid,rev?}`;传输前后核对精确文件身份。 |
+| `POST /api/efast/v1/file/osdownload` | `{docid,rev,savename,authtype:"QUERY_STRING",usehttps:true}`;返回短时签名 GET。 |
+| `POST /api/efast/v1/file/delete` | `{docid}`;HTTP 200 为已删除,202 为进入审核。 |
+| `POST /api/efast/v1/dir/delete` | `{docid,check_upload_process:true}`;处理完整目录树,HTTP 200/202 语义同上。 |
+
+对象 ID 兼容 `id` 与 `docid`;文件夹类型在内部规范为 `directory`。修改时间兼容微秒整数
+`modified` 和 RFC3339 `modified_at`;`custom_metadata.client_mtime` 优先于顶层
+`client_mtime`。目录项目在分页合并后稳定排序。
+
+删除使用一次路径解析得到的对象 ID。目录删除需要 CLI `--recursive`;提交后的网络结果不
+明确时由调用者重新列目录确认,客户端不自动重放非幂等删除。
+
+## 分享链接会话
+
+分享链接入口限定为控制面同源的 `https://disk.pku.edu.cn/link/{link_id}`。输入不能携带
+userinfo、query、fragment 或跨域跳转,link ID 只接受有限长度的字母数字串。
+
+打开链接的流程如下:
+
+1. 使用无 Cookie、无 OAuth 且拒绝重定向的客户端请求
+ `GET /api/shared-link/v1/links/{link_id}`,取得链接类型、项目类型、有效期和访问限制。
+2. `anonymous` 分享再使用新的内存 Cookie jar 请求 `/link/{link_id}`,验证所有重定向仍在
+ 同一 HTTPS origin,并核对落地页信息与规范元数据一致。
+3. 从落地响应提取短时 `link_token:{link_id}` 后丢弃浏览 Cookie 会话,使用独立 Bearer 会话
+ 调用
+ `GET /api/efast/v1/entry-item` 精确匹配分享根对象。
+4. `realname` 分享跳过落地页,使用用户已经建立的 OAuth 会话;缺少登录或访问权限时返回
+ 认证类错误。
+5. 文件夹继续使用 `sub_objects` 分页,文件使用 `file/metadata` 与 `file/osdownload`。下载
+ 对象流沿用相同的完整性检查和本地安全写入流程。
+
+共享会话只列举当前相对目录的一层内容,进入子目录时再请求下一层。相对路径拒绝开头或结尾
+斜线、空段、`.`、`..`、反斜线与 NUL;解析每一级都要求名称唯一且对象类型匹配。
+
+批量下载先解析并预检全部目标,再按输入顺序逐文件传输。每个文件独立校验并原子发布;后续
+文件失败时保留已经完成的文件,并在稳定错误消息中给出完成数量。
+
+短时 link token、Cookie 和签名 URL 仅保存在当前进程内存,作用域限制到同一 HTTPS origin。
+服务端声明 `password_required`、`verify_mobile` 或过期时间时,分别返回稳定错误分类;当前 CLI
+由用户通过网页处理提取码和手机验证。
## 上传控制面
-| 方法与路径 | 精确形状与约束 |
+| 方法与路径 | 形状与约束 |
|---|---|
-| `POST /api/efast/v1/file/osoption` | 无 body;裸响应 `{partminsize,partmaxsize,partmaxnum}`。 |
-| `POST /api/efast/v1/file/predupload` | `{length,slice_md5}`;裸响应 `{match}`。 |
-| `POST /api/efast/v1/file/dupload` | 仅新文件秒传:`{crc32,docid,length,md5,client_mtime,name,ondup:1}`;绝不发送 `editedrev`。 |
-| `POST /api/efast/v1/file/osbeginupload` | 新文件发送父 `docid`、`name`、`ondup:1`、`length`、`client_mtime`、`reqmethod:"PUT"`;覆盖发送现有文件 `docid` 与原始 `editedrev`,省略 name/ondup。响应含 `authrequest,docid,name,rev`。 |
-| `POST /api/efast/v1/file/osinitmultiupload` | 与 begin 的新建/覆盖身份规则相同,但不虚构 `reqmethod`;响应含 `docid,name,rev,uploadid`。 |
-| `POST /api/efast/v1/file/osuploadpart` | `{docid,rev,uploadid,parts:"first-last"}`;范围为包含两端的 `1..10000`,响应 key 是十进制分片号。 |
-| `POST /api/efast/v1/file/oscompleteupload` | `{docid,rev,uploadid,partinfo}`;`partinfo` 按分片号数值排序,每项为 `[etag,size]`。响应是 multipart,含签名请求 JSON 与需要逐字节保留的 XML。 |
-| `POST /api/efast/v1/file/osuploadrefresh` | `{docid,rev,length,multiupload,reqmethod?}`;单次上传带 `reqmethod:"PUT"` 并返回新 `authrequest`,multipart 返回 `uploadid`。 |
-| `POST /api/efast/v1/file/osendupload` | `{docid,rev,crc32,md5,slice_md5,editedrev?}`;完成响应可能不回显 `docid` / `rev`,客户端保留请求身份。 |
-
-`csflevel` 是可选字段;没有值时省略,不发送数值零。协议的 `ondup` 中 `1` 表示冲突、
-`2` 表示自动改名、`3` 表示替换/合并;本工具只在新建时使用 `1`,覆盖采用已解析文件 ID
-和原始 `editedrev`,不会使用自动改名。根据安全决策,已有文件即使命中秒传也不走
-`/dupload`,而是 begin/init、对象 PUT、`osendupload` 的覆盖路径。
-
-## 签名对象存储数据面
-
-控制面可能把签名请求编码成旧式数组 `[method,url,"Header: value",…]`。上传路径只接受
-HTTPS `PUT`,下载路径只接受 HTTPS `GET`;两者都按第一个冒号拆 header,并拒绝 Bearer
-Authorization、HTTP、畸形 header 和任何重定向。PUT 会增加精确 `Content-Length`;GET
-使用 `QUERY_STRING` 授权且不转发控制面附带的 header,禁用透明压缩、拒绝编码响应并保留
-响应长度用于本地完整性检查。OAuth token 从不发送到对象存储主机。
-
-北大部署的对象端点可能返回主机名与有效期正确、但系统 CA 无法建立链的叶证书。显式进程
-变量 `PKU_DRIVE_OBJECT_SPKI_SHA256` 可用于任意控制服务器;变量为空且控制面精确为
-`https://disk.pku.edu.cn` 时,才按顺序读取当前用户的
-`${XDG_CONFIG_HOME:-$HOME/.config}/pku-drive-cli/object-pin.json`、root 管理的
-`/etc/pku-drive-cli/object-pin.json`,最后才是标准系统 CA。用户文件沿用本工具的当前用户
-所有、`0600` / `0700` 和拒绝符号链接规则;系统目录与文件必须分别为 root 所有的 `0755`
-和 `0644`,并通过 dirfd、`O_NOFOLLOW` 与 `fstat` 读取。较高优先级的文件一旦存在但损坏或
-不安全就失败关闭,不会静默回退。持久文件不会影响非北大控制面。pin 模式只替换对象数据面
-的 CA 链验证,仍逐次校验证书主机名、有效期与 SPKI,并禁用该隔离 transport 的代理;
-控制面 TLS 不降级。
-
-单次或分片 PUT 从 `ETag` 取完成标识,缺失时兼容 `Content-MD5`。下载 GET 的响应流只在
-HTTP 200 后交给本地原子写入流程,并核验响应长度及文件 metadata 中可用的 MD5。明确的
-`RequestTimeTooSkewed`、`ExpiredToken`、`SecurityTokenExpired`、`Request has expired`
-以及 Qiniu 的 `expired token` / `token out of date` / `request time too skewed` 才触发
-授权刷新;普通 `403` 和 `SignatureDoesNotMatch` 不会被宽泛认定为过期。错误 body 有
-64 KiB 上限且只保留净化后的分类,不记录签名 URL、query、Authorization 值或原始 body。
-
-## 已处理的响应差异与验证边界
-
-- `authrequest` 可直接是签名数组;multipart 完成 JSON 部分也兼容外层
- `{authrequest: ...}`。
-- multipart 完成响应 JSON MIME 必须是 `application/json`,XML 必须是
- `application/xml` 或 `text/xml`;空、重复、未知 part 均拒绝。
-- 完成上传响应可省略 `docid` / `rev`,但不能用服务端缺字段绕过本地已知身份。
-- `osdownload` 的 `authrequest` 必须是签名 GET;服务端若返回 PUT、HTTP URL、Bearer
- Authorization 或畸形 header 会被拒绝。QUERY_STRING 模式下其他控制面 header 不会转发到
- 对象域,授权信息只来自签名 URL。
-- 文件和目录删除均使用一次精确路径解析得到的对象 ID;目录删除要求 CLI 显式
- `--recursive`。删除是非幂等请求,网络结果不明确时直接返回错误,不重放也不凭名字路径
- 猜测已经删除。
-- 所有 JSON 响应都有体积上限;上传流程的非幂等请求若连接在提交后丢失,会保存无秘密的
- durable phase 并先按远端精确身份协调,不盲目重放。
-- 本地完整协议测试使用彼此独立的 TLS 控制面和对象面,覆盖 refresh、429/503、签名过期、
- 响应丢失、完整性不匹配、中断及续传;测试证书只加入测试专用 root pool,从未使用
- `InsecureSkipVerify`。
-
-本文件中的协议断言以公开文档、静态客户端证据和可重复的无秘密测试分别标注;模拟测试
-用于验证实现边界,不替代部署方对生产环境兼容性的持续确认。
+| `POST /api/efast/v1/file/osoption` | 返回 `partminsize`、`partmaxsize`、`partmaxnum`。 |
+| `POST /api/efast/v1/file/predupload` | `{length,slice_md5}`;返回秒传匹配状态。 |
+| `POST /api/efast/v1/file/dupload` | 新文件秒传:`{crc32,docid,length,md5,client_mtime,name,ondup:1}`。 |
+| `POST /api/efast/v1/file/osbeginupload` | 新建发送父 ID、名称、长度与 `reqmethod:"PUT"`;覆盖发送文件 ID 和原始 `editedrev`。 |
+| `POST /api/efast/v1/file/osinitmultiupload` | 初始化 multipart;新建与覆盖沿用相同身份规则。 |
+| `POST /api/efast/v1/file/osuploadpart` | `{docid,rev,uploadid,parts:"first-last"}`;分片号范围为 `1..10000`。 |
+| `POST /api/efast/v1/file/oscompleteupload` | `{docid,rev,uploadid,partinfo}`;分片按数值排序,响应含签名请求和完成 XML。 |
+| `POST /api/efast/v1/file/osuploadrefresh` | 刷新单次或 multipart 上传授权。 |
+| `POST /api/efast/v1/file/osendupload` | `{docid,rev,crc32,md5,slice_md5,editedrev?}`;提交并核验最终 metadata。 |
+
+`csflevel` 有有效值时才发送。新建使用 `ondup:1` 报告冲突;覆盖绑定已经解析的文件 ID 与
+revision。已有文件即使命中秒传也走覆盖协议,避免把秒传结果绑定到错误对象。
+
+multipart 续传状态保存本地文件指纹、远端身份、upload ID、分片 ETag 和 durable phase。
+发生连接丢失时先协调远端精确状态,再决定继续或报错。
+
+## 签名对象存储
+
+控制面可把对象请求编码为 `[method,url,"Header: value",…]`。上传只接受 HTTPS `PUT`,下载
+只接受 HTTPS `GET`;解析器拒绝重定向、HTTP URL、畸形 header 和 Bearer Authorization。
+OAuth token 不会发往对象存储主机。
+
+下载采用 `QUERY_STRING` 授权,不转发控制面 header,并禁用透明压缩。HTTP 200 响应流先
+核验内容长度和 metadata 中可用的 MD5,再由本地原子写入器发布。上传从 `ETag` 读取完成
+标识,缺失时兼容 `Content-MD5`。
+
+北大对象端点可使用发行包中的 SPKI pin。控制面为 `https://disk.pku.edu.cn` 时,读取顺序是
+用户配置 `${XDG_CONFIG_HOME:-$HOME/.config}/pku-drive-cli/object-pin.json`、系统配置
+`/etc/pku-drive-cli/object-pin.json`、标准系统 CA。pin 模式继续验证主机名、证书有效期和
+SPKI,并为对象面使用隔离 transport;控制面始终使用系统 CA。
+
+明确的签名过期响应会触发一次授权刷新。其他 `403` 保持服务端拒绝语义。错误 body 设有
+64 KiB 上限,只输出净化后的分类,不记录 query、Cookie、Authorization 或原始响应。
+
+## 测试边界
+
+本地协议测试使用相互独立的 TLS 控制面与对象面,覆盖 OAuth refresh、匿名与实名分享、
+目录按需浏览、429/503、签名过期、响应丢失、上传续传、完整性不匹配和中断清理。测试证书
+只加入测试专用 root pool。
+
+协议字段变化时,应先添加最小 fixture 固化服务端响应,再更新兼容解析;新的兼容分支需要
+保持同源校验、响应体积上限、精确对象身份和秘密净化规则。
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index aba066c..388c009 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -1,359 +1,203 @@
-# 北大网盘 CLI:完整参考与故障排查
+# 故障排查与高级说明
-本文集中记录安装细节、登录方式、完整命令语义、诊断方法和安全边界。首次安装与日常使用
-请先阅读[简明 README](../README.md)。
+日常安装和命令示例见[项目 README](../README.md)。本文用于定位认证、网络、分享链接和传输
+问题,并记录脚本退出码与本地状态位置。
-`pku-drive` 是面向 Linux 服务器和 SSH 终端的非官方北大网盘 CLI,通过
-`https://disk.pku.edu.cn` 的 OAuth 2.0 和 AnyShare 文档 API 工作。它不安装或驱动官方
-Electron 客户端,也不依赖 Python、Node.js、rclone、WebDAV 或 FUSE。
+## 运行与构建环境
-## 环境要求
-
-- 运行环境为 Linux amd64;建议使用内核 6.6 或更新版本。
-- 发布二进制无 CGO 运行时依赖。系统需要 CA 证书;打开服务器浏览器时建议安装
+- 发布包支持 Linux amd64,运行时需要系统 CA 证书;自动打开服务器浏览器时建议安装
`xdg-utils`。
-- 构建需要 Go 1.22、GNU Make 和 GNU coreutils;`make test` 的 race detector 还需要 C
- 编译工具链。
-- 制作发布包另外需要 `dpkg-deb`、`jq`、GNU tar、gzip 和 `sha256sum`。
-- 首次登录需要能从服务器访问 `disk.pku.edu.cn:443`,并在浏览器完成北京大学统一身份
- 认证。
-
-## 安装和升级
-
-### Debian / Ubuntu 系统安装
-
-从 [GitHub Releases](https://github.com/Findddx/pku-drive-cli/releases) 下载同一版本的
-`.deb` 和 `SHA256SUMS`,核验后安装:
-
-```bash
-sha256sum --ignore-missing -c SHA256SUMS
-sudo apt install ./pku-drive-cli_0.2.0-1_amd64.deb
-/usr/bin/pku-drive version
-```
+- 源码构建需要 Go 1.22、GNU Make 和 GNU coreutils;race detector 需要 C 编译工具链。
+- 制作发布包还需要 `dpkg-deb`、`jq`、GNU tar、gzip 和 `sha256sum`。
+- 登录、列目录和传输需要服务器可访问 `disk.pku.edu.cn:443` 及响应中返回的对象存储主机。
-系统包安装以下主要文件:
-
-```text
-/usr/bin/pku-drive
-/etc/pku-drive-cli/object-pin.json
-/usr/share/doc/pku-drive-cli/
-```
+## 安装与升级
-程序和系统对象证书 pin 可供所有用户读取,但 OAuth 凭据、配置、锁和上传续传状态按用户
-分别保存在各自的 XDG 目录。每个用户必须以自己的普通账户执行 `pku-drive login`。不要用
-`sudo` 执行 `login`、`status`、`ls`、`mkdir`、`put`、`get`、`rm` 或 `logout`。
+### 命令未找到或版本不一致
-若 PATH 命中了以前的用户安装版:
+检查 shell 实际使用的程序:
```bash
type -a pku-drive
/usr/bin/pku-drive version
-```
-
-升级时安装新 `.deb`。卸载程序但保留系统 pin 使用 `sudo apt remove pku-drive-cli`;同时
-删除系统 pin 使用 `sudo apt purge pku-drive-cli`。两者都不会删除用户 HOME 中的凭据和
-状态文件。
-
-### tar 包系统安装
-
-没有 Debian 包管理器时,从 Releases 下载同一版本的 tar 包和校验文件:
-
-```bash
-sha256sum --ignore-missing -c SHA256SUMS
-sudo tar -xzf pku-drive-cli-0.2.0-linux-amd64.tar.gz \
- --strip-components=1 -C /
/usr/local/bin/pku-drive version
```
-tar 包不受包管理器跟踪。不要与 `.deb` 同时安装。
+`.deb` 默认安装到 `/usr/bin/pku-drive`,tar 包默认安装到
+`/usr/local/bin/pku-drive`。避免同时安装两种系统包。升级时安装新版包并重新执行
+`pku-drive version`;升级不会删除用户凭据。
-### 从源码构建
+每位系统用户都要用自己的普通账号执行 `pku-drive login`。使用 `sudo` 登录会把凭据保存到
+root 的配置目录,普通用户随后仍会显示未登录。
-```bash
-git clone https://github.com/Findddx/pku-drive-cli.git
-cd pku-drive-cli
-make test
-make build
-./bin/pku-drive version
-```
+### 源码安装
-`make install` 把开发构建原子安装到当前用户的 `~/.local/bin/pku-drive`;不要执行
-`sudo make install`。如果 PATH 不含该目录,可在 shell 配置中加入:
+`make install` 原子安装到当前用户的 `~/.local/bin/pku-drive`。若 shell 找不到它:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
-`make package` 还需要一个已经通过独立可信渠道核验的对象证书 pin。默认读取
-`${XDG_CONFIG_HOME:-$HOME/.config}/pku-drive-cli/object-pin.json`,也可指定规范化绝对路径:
+打包需要已通过可信渠道核验的对象证书 pin。默认读取
+`${XDG_CONFIG_HOME:-$HOME/.config}/pku-drive-cli/object-pin.json`,也可以指定绝对路径:
```bash
PACKAGE_PIN_FILE=/absolute/path/object-pin.json make package
```
-pin 文件必须归当前用户所有、权限 `0600`、只有一个硬链接,其父目录必须归当前用户所有且
-权限 `0700`。打包要求 Git 工作树干净,并在 `dist/` 生成 `.deb`、tar 包和
-`SHA256SUMS`。构建和打包均使用普通用户。
+pin 文件须归当前用户所有、权限为 `0600`,父目录权限为 `0700`。打包命令要求 Git 工作树
+干净。
-## 登录与认证
+## 登录与回调
-### 浏览器运行在服务器、VNC 或 X11 中
+### SSH 终端没有收到浏览器回调
-```bash
-pku-drive login
-pku-drive status --json
-```
-
-当 `DISPLAY` 或 `WAYLAND_DISPLAY` 非空时,工具尝试调用 `xdg-open`。没有自动出现窗口时,
-手动在服务器浏览器中打开终端打印的授权 URL;命令仍会继续等待回调。
-
-`login` 若发现 access token 仍新鲜,或 refresh token 刷新成功,会直接返回而不打开
-浏览器。这是正常行为,可用 `status` 验证。
-
-### 纯 SSH:浏览器运行在本地电脑
-
-推荐使用回调粘贴模式,不需要 SSH 端口转发:
-
-1. 在服务器 SSH 会话中运行并保持会话等待:
-
- ```bash
- env -u DISPLAY -u WAYLAND_DISPLAY pku-drive login --paste-callback
- ```
-
-2. 在本地浏览器打开终端打印的完整授权 URL,完成统一身份认证。
-3. 浏览器跳到 `http://127.0.0.1:PORT/callback?code=...&state=...` 后,页面显示无法连接是
- 正常的;这里的 `127.0.0.1` 指本地电脑。
-4. 从地址栏复制**完整回调 URL**,粘贴到仍在等待的 SSH 终端并回车。不要只复制
- `code`,不要修改或解码 URL。
-5. 看到 `Login successful.` 后运行 `pku-drive status --json`。
-
-粘贴输入不回显。回调 URL 含一次性 code 和 `state`,不要写入 shell 历史、日志、工单或
-聊天。非交互式 SSH 命令需要加 `ssh -t` 分配 PTY。异常中断后终端若不再回显,可执行
-`stty echo` 或 `reset`。
+浏览器运行在本地电脑时使用粘贴模式:
-### Windows PowerShell + OpenSSH
-
-PowerShell 窗口中先 SSH 到服务器,再运行粘贴模式:
-
-```powershell
-ssh.exe USER@SERVER
-# 以下命令在服务器 shell 内运行:
+```bash
env -u DISPLAY -u WAYLAND_DISPLAY pku-drive login --paste-callback
```
-在本地打开授权 URL 时使用单引号,避免 URL 中的 `&` 被 PowerShell 解释:
+在本地浏览器完成认证后,复制地址栏的完整
+`http://127.0.0.1:PORT/callback?code=...&state=...` URL,粘贴到等待中的 SSH 终端。输入会隐藏。
+只复制 `code`、使用旧回调或修改 URL 都会导致校验失败。重新执行登录后只使用最新链接。
+
+PowerShell 可用以下方式打开终端给出的授权链接:
```powershell
Start-Process '完整授权URL'
```
-认证完成后从浏览器地址栏复制完整回调 URL,粘贴回 SSH 窗口。不要把回调 URL 当作
-PowerShell 命令执行。
+单引号可避免 `&` 被 PowerShell 解析。回调 URL 应粘贴到 SSH 会话内的输入提示,不要在
+PowerShell 中执行。通过非交互 SSH 启动时使用 `ssh -t` 分配终端。
-### 可选:SSH 端口转发自动回调
-
-普通 `pku-drive login` 会在授权 URL 的 `redirect_uri` 中显示服务器回调端口。必须在打开
-授权 URL 前,从本地另开终端建立相同端口的转发:
+异常退出后若终端停止回显,运行:
```bash
-PORT=授权URL中显示的端口
-ssh -o ExitOnForwardFailure=yes -N \
- -L "127.0.0.1:${PORT}:127.0.0.1:${PORT}" USER@SERVER
+stty echo
+reset
```
-两端都必须使用 `127.0.0.1` 和同一端口。若 sshd 禁止 TCP forwarding,请改用
-`--paste-callback`,不要把回调监听暴露到 `0.0.0.0`。
-
-### 登录时限、重新认证和切换账号
+### SSH 端口转发
-`login` 的五分钟时限从命令启动开始计算,包括凭据锁等待、刷新、浏览器授权、回调和 code
-换 token。超时、`Ctrl-C`、SSH 断开后,旧 URL、旧 `state` 和旧端口都不可复用,应重新
-运行登录命令并只使用最新 URL。
-
-普通网盘命令只自动刷新 token,不会自行打开浏览器。遇到 `login required` 时运行:
-
-```bash
-pku-drive login --paste-callback
-pku-drive status
-```
-
-切换账号:
-
-```bash
-pku-drive logout
-pku-drive login --paste-callback
-```
-
-普通注销若无法在服务端撤销 token,会保留本地凭据并报错。只有明确接受 token 可能仍在
-服务端有效时,才使用:
+普通 `pku-drive login` 也可借助本地端口转发接收自动回调。先读取授权 URL 中的端口,再从
+本地另开终端:
```bash
-pku-drive logout --local-only
-```
-
-## 完整命令参考
-
-```text
-pku-drive login [--paste-callback] [--json]
-pku-drive status [--json]
-pku-drive ls [REMOTE_PATH] [--json]
-pku-drive mkdir REMOTE_PATH [--parents] [--json]
-pku-drive put LOCAL_FILE REMOTE_FILE_OR_DIR [--overwrite] [--quiet] [--json]
-pku-drive get REMOTE_FILE LOCAL_FILE_OR_EXISTING_DIR [--overwrite] [--quiet] [--json]
-pku-drive rm REMOTE_PATH [--recursive] [--json]
-pku-drive logout [--local-only] [--json]
-pku-drive version [--json]
-```
-
-布尔选项可写成 `--flag`、`--flag=true` 或 `--flag=false`,也可放在普通参数前后。同一选项
-重复会报错;用 `--` 结束选项解析。
-
-远端路径必须是以 `/` 开头的绝对名字路径。重复斜线和 `.` 会规范化,`..` 会被拒绝。
-含空格或中文的路径建议用单引号包围。
-
-### 列目录和创建目录
-
-```bash
-pku-drive ls /
-pku-drive ls '/个人文档/项目'
-pku-drive mkdir '/个人文档/项目/数据'
-pku-drive mkdir '/个人文档/项目/数据/2026' --parents
+PORT=授权URL中的端口
+ssh -o ExitOnForwardFailure=yes -N \
+ -L "127.0.0.1:${PORT}:127.0.0.1:${PORT}" USER@SERVER
```
-省略 `ls` 的路径等同于 `/`。`mkdir` 不加 `--parents` 时父目录必须存在;已有同名目录视为
-成功,同名文件是冲突。
+服务器提示 `administratively prohibited` 表示 sshd 禁止转发,改用 `--paste-callback`。
+回调监听应保持在 `127.0.0.1`。
-### 上传
+### 登录状态异常
-```bash
-pku-drive put ./report.csv '/个人文档/项目/report.csv'
-pku-drive put ./report.csv '/个人文档/项目/'
-pku-drive put ./report.csv '/个人文档/项目/report.csv' --overwrite
-pku-drive put ./large.bin '/个人文档/项目/large.bin' --quiet
-```
+| 现象 | 处理 |
+|---|---|
+| `login` 立即成功,没有浏览器 | 当前 token 仍有效或已成功刷新;运行 `pku-drive status --json`。 |
+| `login required` | 运行 `pku-drive login`;纯 SSH 使用 `--paste-callback`。 |
+| `state` 校验失败 | 关闭并发登录流程,重新执行登录并使用新 URL。 |
+| 登录超过五分钟 | 重新执行登录;授权 URL、回调端口和 `state` 均为一次性。 |
+| 需要切换账号 | 先 `pku-drive logout`,再重新登录。 |
-参数顺序始终是本地文件在前、网盘路径在后。远端目标以 `/` 结尾或解析为已有目录时,保留
-本地文件名。父目录必须存在。默认绝不覆盖同名文件;`--overwrite` 才创建已有文件的新
-版本,不会自动改名。
+普通注销会尝试在服务端撤销 token。网络故障导致撤销失败时,本地凭据会保留;确认接受远端
+token 可能继续有效后,才使用 `pku-drive logout --local-only`。
-本地源必须是普通文件,目录和符号链接会被拒绝。工具根据服务端限制选择秒传、单次 PUT
-或 multipart。分片上传中断后,重复完全相同的命令即可校验并续传缺失分片;覆盖场景仍需
-带 `--overwrite`。本地文件发生变化、状态损坏或远端目标身份不一致时不会冒险复用状态。
+## 分享链接
-### 下载
+| 现象 | 处理 |
+|---|---|
+| 公开分享提示需要登录 | 链接可能被改为组织范围,或分享策略已更新;登录后重试。 |
+| 组织范围分享无权限 | 确认当前登录账号属于分享指定范围,并让分享者检查权限。 |
+| 链接要求提取码 | 当前版本不处理提取码;请让分享者提供无需提取码的链接或通过网页下载。 |
+| 链接要求手机验证 | 当前版本不处理手机验证;请使用浏览器完成下载。 |
+| 交互界面无法启动 | 需要 TTY;脚本中用 `ls --share ... --json` 和带明确相对路径的 `get --share ... --json`。 |
+| 分享内文件找不到 | 先列出分享根目录,再逐层对照大小写和名称;分享路径不以 `/` 开头。 |
+| 分享已失效 | 请分享者检查到期时间、删除状态和访问范围后重新生成链接。 |
+| 批量下载中途失败 | 已完成文件会保留,错误会显示完成数量;重新选择未完成文件,或确认覆盖后加入 `--overwrite`。 |
-```bash
-pku-drive get '/个人文档/项目/report.csv' ./report.csv
-pku-drive get '/个人文档/项目/report.csv' ./downloads/
-pku-drive get '/个人文档/项目/report.csv' ./report.csv --overwrite
-pku-drive get '/个人文档/项目/large.bin' ./large.bin --quiet
-```
+分享 URL、临时 link token、Cookie 和下载签名可能授予文件访问权限。不要把它们写入日志、
+Issue、聊天记录或公开的命令历史。建议将链接放入受控的脚本变量,使用完后清除历史。
-参数顺序始终是网盘文件在前、本地目标在后。目标是已有目录时,使用网盘文件名;目标以
-`/` 结尾时,该目录必须已经存在。远端目录不能下载。
+## 网络与代理
-下载先在目标目录创建私有临时文件,校验响应长度和服务端可用的 MD5 后再原子发布。默认
-拒绝覆盖已有本地文件;`--overwrite` 仅覆盖安全的普通文件,不跟随符号链接。当前下载不
-支持断点续传;中断后临时文件会清理,重新执行命令即可。
+控制面使用 Go 标准代理环境变量 `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 及其小写形式。
+变量应在启动命令前设置。对象存储在启用 SPKI pin 时使用隔离连接;标准 CA 模式遵循代理
+环境。浏览器访问本地 loopback 回调不经过 CLI 代理。
-### 删除
+基础连通性检查:
```bash
-pku-drive rm '/个人文档/项目/report.csv'
-pku-drive rm '/个人文档/项目/旧数据' --recursive
+getent hosts disk.pku.edu.cn
+curl -I --connect-timeout 10 https://disk.pku.edu.cn/
```
-文件不需要 `--recursive`。任何目录都必须显式加 `--recursive`,因为服务端目录删除接口会
-处理整棵目录树。删除通常将对象移入网盘回收站;若服务器启用删除审核,成功输出为已提交
-审核。虚拟根 `/` 和顶级文档库不能删除。
+校园 VLAN、代理或 tunnel 环境出现长时间无响应时,检查 DNS、MTU、代理规则和到
+`disk.pku.edu.cn:443` 的连接。`--quiet` 会隐藏周期进度,排查时先移除该选项。
-网络在删除请求提交后中断时,结果可能不明确。工具会直接返回错误,不会盲目重放非幂等
-删除;先重新 `ls` 确认当前状态,再决定下一步。
+`object-store trust configuration rejected` 表示对象证书 pin 缺失、损坏、权限不安全或证书
+已经轮换。通过可信渠道核验新的 SPKI 后更新配置;工具没有跳过 TLS 校验的选项。
-### 进度输出
+## 文件传输与删除
-上传和下载的进度写到 stderr,最终结果写到 stdout。`--quiet` 只抑制周期进度,不抑制最终
-结果或错误。stderr 不是交互式终端时默认不输出周期进度。
+| 现象 | 处理 |
+|---|---|
+| 网盘路径不存在 | 从 `pku-drive ls /` 开始逐级检查文档库与目录名称。 |
+| 上传目标已存在 | 换文件名,或确认覆盖后加入 `--overwrite`。 |
+| 本地下载目标已存在 | 换本地路径,或确认覆盖后加入 `--overwrite`。 |
+| 上传中断 | 对未变化的本地文件和同一远端目标重复原命令,工具会核验并续传已有分片。 |
+| 下载中断 | 重新运行命令;未完成的临时文件会清理。 |
+| 删除目录要求 recursive | 确认目录树后加入 `--recursive`。 |
+| 删除时网络中断 | 先重新列目录确认结果,再决定是否重试。 |
-## JSON 和退出码
+上传源必须是普通文件。下载会在目标目录写入私有临时文件,完成长度与可用 MD5 校验后原子
+发布;`--overwrite` 仅覆盖安全的普通文件,不跟随符号链接。上传和下载的进度写到 stderr,
+最终结果写到 stdout。
-支持 `--json` 的命令在 stdout 只输出一个最终 JSON 对象;诊断和登录授权链接仍写到
-stderr。脚本不要把 stderr 合并进 JSON 管道,并应同时检查退出码和 `ok`。
+## JSON 与退出码
-成功对象共同包含 `ok:true` 和 `operation`。下面只展示字段形状,值不是实际账户数据:
-
-```json
-{"ok":true,"operation":"get","remote_path":"/个人文档/项目/report.csv","remote_id":"example-id","revision":"1","local_path":"/tmp/report.csv","size":123}
-{"ok":true,"operation":"rm","remote_path":"/个人文档/项目/report.csv","remote_id":"example-id","type":"file","status":"deleted","pending_review":false}
-```
-
-失败对象形如:
+`--json` 模式在 stdout 输出一个最终 JSON 对象,诊断与授权链接写到 stderr。脚本应同时
+检查退出码和 `ok`。典型失败结果:
```json
{"ok":false,"category":"auth","message":"login required"}
```
-| 代码 | JSON `category` | 含义 |
+| 退出码 | JSON `category` | 含义 |
|---:|---|---|
| 0 | — | 成功 |
-| 2 | `usage` | 命令、参数、选项或传输计划错误 |
+| 2 | `usage` | 参数、选项或传输计划错误 |
| 3 | `auth` | 未登录、刷新失败或认证失败 |
-| 4 | `remote` | 远端不存在、无权限、名称冲突或不允许删除 |
+| 4 | `remote` | 远端不存在、无权限、冲突或分享失效 |
| 5 | `network` | 网络、限流或服务端错误 |
| 6 | `local` | 本地文件、输出或安全存储失败 |
-| 7 | `integrity` | 续传状态或传输完整性核验失败 |
-| 130 | `interrupted` | `SIGINT`、`SIGTERM`、命令超时或取消 |
-
-`login` 时限为五分钟;`status`、`ls`、`mkdir`、`rm`、`logout` 为两分钟;`put` 和 `get`
-为 24 小时。
+| 7 | `integrity` | 续传状态或传输完整性失败 |
+| 130 | `interrupted` | 信号、超时或用户取消 |
-## 常见故障
+登录时限为五分钟;元数据命令通常为两分钟;上传和下载最长运行 24 小时。
-| 现象 | 处理 |
-|---|---|
-| `login` 立即成功但没有浏览器 | 现有 token 仍有效或刷新成功;运行 `pku-drive status --json`。 |
-| 已打印授权 URL,但服务器没有浏览器窗口 | 手动在服务器浏览器打开;若浏览器在 SSH 客户端,取消后改用 `login --paste-callback`。 |
-| 浏览器跳到 `127.0.0.1` 后显示无法连接 | 粘贴模式下复制地址栏完整 URL 并粘贴回终端;普通模式应重新登录并预先建立 SSH 转发。 |
-| `state` 校验失败 | 使用了旧 URL、修改了 URL 或混用了并发登录;终止其他登录并重新开始。 |
-| `administratively prohibited` | sshd 禁止端口转发;改用 `--paste-callback`。 |
-| `login required` | 运行 `pku-drive login` 或 `pku-drive login --paste-callback`。 |
-| `object-store trust configuration rejected` | 对象证书 pin 缺失、损坏、权限不安全或证书已轮换;通过可信渠道核验新 SPKI 后更新,不要关闭 TLS 校验。 |
-| `remote path ... not found` | 先运行 `pku-drive ls /`,逐级确认顶级文档库和目录名称。 |
-| 上传提示目标已存在 | 换远端文件名,或确认需要覆盖后加 `--overwrite`。 |
-| 下载提示本地目标已存在 | 换本地目标,或确认需要覆盖后加 `--overwrite`。 |
-| 删除目录提示需要 recursive | 确认整棵目录树都应移入回收站,再加 `--recursive`。 |
-| 找不到 `pku-drive` | 运行 `type -a pku-drive`;系统包使用 `/usr/bin/pku-drive`,tar 包使用 `/usr/local/bin/pku-drive`。 |
-| 命令长时间无进度 | 去掉 `--quiet` 并在交互终端重试;检查服务器 DNS、代理、隧道和到 `disk.pku.edu.cn:443` 的连通性。 |
-
-OAuth 和控制面请求使用 Go 标准代理变量 `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 及其
-小写形式;代理变量必须在启动命令前设置。PKU 对象存储在启用 SPKI pin 时使用不经过代理
-的隔离传输;标准 CA 模式沿用 Go 的代理环境。浏览器访问本地 loopback 回调不经过 CLI 的
-代理设置。
-
-## 本地文件与安全边界
+## 本地状态与安全边界
| 内容 | 默认位置 | 权限 |
|---|---|---:|
-| 配置与 OAuth 凭据 | `~/.config/pku-drive-cli/` | 目录 `0700`、文件 `0600` |
+| OAuth 配置与凭据 | `~/.config/pku-drive-cli/` | 目录 `0700`、文件 `0600` |
| 用户对象证书 pin | `~/.config/pku-drive-cli/object-pin.json` | `0600` |
| 系统对象证书 pin | `/etc/pku-drive-cli/object-pin.json` | `root:root 0644` |
| 上传续传状态 | `~/.local/state/pku-drive-cli/uploads/` | 目录 `0700`、文件 `0600` |
-工具拒绝配置和状态路径中的符号链接、错误所有者、异常类型或过宽权限,不会静默修复已有
-对象。`credentials.json` 含 access token、refresh token 和动态客户端 secret,不要提交、
-同步或复制。续传状态不保存 token、Authorization header 或完整签名对象 URL。
+`credentials.json` 包含 access token、refresh token 和动态客户端 secret,不应复制、同步或
+提交。共享链接会话只在命令进程内存中保存,不写入凭据目录。续传状态不保存 token、
+Authorization header 或完整签名 URL。
-控制面始终使用系统 CA 和主机名校验,不提供 `--insecure`。对象数据面可以使用经过核验的
-SPKI pin,仍校验证书主机名和有效期。OAuth Bearer token 不会发送到对象存储主机,签名
-请求的重定向会被拒绝。
+控制面始终验证系统 CA 与主机名。OAuth Bearer token 只发往北大网盘控制面;对象存储使用
+服务端签发的短期下载或上传授权。
-## 明确不支持
+## 功能范围
-- Windows 或 macOS 原生二进制;这些系统可以作为 SSH 客户端和浏览器使用。
-- 下载目录、目录同步、WebDAV、FUSE、后台 daemon 或自动更新。
-- 下载断点续传;上传支持受校验的 multipart 续传。
-- 移动、复制、重命名或操作回收站内容。
-- 读取或复用 Chrome Cookie、Local Storage、登录数据库或 remote debugging。
-- 把北大网盘当作用户可配置的 S3,或宽泛关闭 TLS 校验。
+当前发布版面向 Linux amd64 单机命令行工作流,支持单文件上传、单文件下载、分享文件批量
+选择、上传分片续传和网盘目录管理。目录同步、FUSE/WebDAV 挂载、后台 daemon、回收站管理、
+移动/复制/重命名和下载断点续传尚未纳入命令集。
-协议依据和兼容处理见 [API 与兼容性说明](api-notes.md)。
+协议端点和兼容处理见 [API 与兼容性说明](api-notes.md)。
diff --git a/internal/anyshare/share.go b/internal/anyshare/share.go
new file mode 100644
index 0000000..f812cae
--- /dev/null
+++ b/internal/anyshare/share.go
@@ -0,0 +1,29 @@
+package anyshare
+
+import (
+ "context"
+ "net/http"
+)
+
+// EntryItems returns the document roots exposed to an anonymous shared-link
+// session. Authentication is supplied by the short-lived link token used to
+// construct the Client.
+func (c *Client) EntryItems(ctx context.Context) ([]Item, error) {
+ var wireItems []itemWire
+ if err := c.callJSON(ctx, http.MethodGet, "/api/efast/v1/entry-item", nil, true, &wireItems); err != nil {
+ return nil, err
+ }
+ items := make([]Item, 0, len(wireItems))
+ for _, wireItem := range wireItems {
+ stableType := wireItem.Type
+ if stableType == "folder" {
+ stableType = "directory"
+ }
+ item, err := normalizeItem(wireItem, stableType)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ return items, nil
+}
diff --git a/internal/anyshare/share_test.go b/internal/anyshare/share_test.go
new file mode 100644
index 0000000..87515d2
--- /dev/null
+++ b/internal/anyshare/share_test.go
@@ -0,0 +1,35 @@
+package anyshare_test
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "github.com/Findddx/pku-drive-cli/internal/anyshare"
+)
+
+func TestEntryItemsUsesAnonymousEntryEndpointAndNormalizesItems(t *testing.T) {
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assertAuthorizedRequest(t, r, http.MethodGet, "/api/efast/v1/entry-item")
+ _, _ = io.WriteString(w, `[
+ {"id":"gns://shared-folder","name":"Shared Folder","type":"folder","rev":"folder-r1","size":-1,"modified_at":"2026-01-02T03:04:05.123456Z"},
+ {"docid":"gns://shared-file","name":"report.tsv","type":"file","rev":"file-r1","size":17,"modified":1767323045123456}
+ ]`)
+ }))
+ defer server.Close()
+
+ items, err := newTestClient(server, staticToken("fixture-access")).EntryItems(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []anyshare.Item{
+ {ID: "gns://shared-folder", Name: "Shared Folder", Type: "directory", Rev: "folder-r1", Size: -1, Modified: 1767323045123456},
+ {ID: "gns://shared-file", DocID: "gns://shared-file", Name: "report.tsv", Type: "file", Rev: "file-r1", Size: 17, Modified: 1767323045123456},
+ }
+ if !reflect.DeepEqual(items, want) {
+ t.Fatalf("items = %#v, want %#v", items, want)
+ }
+}
diff --git a/internal/cli/run.go b/internal/cli/run.go
index e9e66ab..9e9d629 100644
--- a/internal/cli/run.go
+++ b/internal/cli/run.go
@@ -16,6 +16,7 @@ import (
"github.com/Findddx/pku-drive-cli/internal/apperr"
"github.com/Findddx/pku-drive-cli/internal/output"
+ "github.com/Findddx/pku-drive-cli/internal/picker"
"github.com/Findddx/pku-drive-cli/internal/version"
)
@@ -48,7 +49,10 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
allowed["overwrite"], allowed["quiet"] = true, true
}
if command == "get" {
- allowed["overwrite"], allowed["quiet"] = true, true
+ allowed["overwrite"], allowed["quiet"], allowed["share"] = true, true, true
+ }
+ if command == "ls" {
+ allowed["share"] = true
}
if command == "rm" {
allowed["recursive"] = true
@@ -64,7 +68,7 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
var flagOutput bytes.Buffer
flags.SetOutput(&flagOutput)
jsonMode := flags.Bool("json", false, "emit JSON")
- var parents, overwrite, quiet, recursive, localOnly, pasteCallback *bool
+ var parents, overwrite, quiet, recursive, localOnly, pasteCallback, shareMode *bool
if command == "login" {
pasteCallback = flags.Bool("paste-callback", false, "paste the browser callback URL")
}
@@ -78,6 +82,10 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
if command == "get" {
overwrite = flags.Bool("overwrite", false, "overwrite existing file")
quiet = flags.Bool("quiet", false, "suppress progress")
+ shareMode = flags.Bool("share", false, "download from a shared link")
+ }
+ if command == "ls" {
+ shareMode = flags.Bool("share", false, "list a shared link")
}
if command == "rm" {
recursive = flags.Bool("recursive", false, "delete a directory tree")
@@ -93,7 +101,8 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
}
renderer := output.NewRenderer(stdout, stderr, *jsonMode)
positionals := flags.Args()
- if err := validateArity(command, positionals); err != nil {
+ isShare := shareMode != nil && *shareMode
+ if err := validateArity(command, positionals, isShare); err != nil {
return renderError(renderer, err)
}
@@ -135,11 +144,14 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
fields := structFields(result)
human := "Not logged in."
if result.LoggedIn {
- human = fmt.Sprintf("Logged in as %s on %s.", result.Account, result.Server)
+ human = fmt.Sprintf("Logged in as %s on %s.", output.TerminalText(result.Account), output.TerminalText(result.Server))
}
return renderSuccess(renderer, command, fields, human)
}
case "ls":
+ if isShare {
+ return runShareList(commandCtx, renderer, deps, positionals)
+ }
path := "/"
if len(positionals) == 1 {
path = positionals[0]
@@ -162,7 +174,7 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
})
lines := make([]string, len(entries))
for i, entry := range entries {
- lines[i] = fmt.Sprintf("%s\t%s\t%d", entry.Type, entry.RemotePath, entry.Size)
+ lines[i] = fmt.Sprintf("%s\t%s\t%d", output.TerminalText(entry.Type), output.TerminalText(entry.RemotePath), entry.Size)
}
return renderSuccess(renderer, command, map[string]any{"entries": entries}, strings.Join(lines, "\n"))
}
@@ -174,7 +186,7 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
result, err = deps.Mkdir(commandCtx, positionals[0], *parents)
}
if err == nil {
- return renderSuccess(renderer, command, structFields(result), "Directory ready: "+result.RemotePath)
+ return renderSuccess(renderer, command, structFields(result), "Directory ready: "+output.TerminalText(result.RemotePath))
}
case "put":
var result PutResult
@@ -186,10 +198,13 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
}
if err == nil {
progress.Finished()
- return renderSuccess(renderer, command, structFields(result), "Uploaded: "+result.RemotePath)
+ return renderSuccess(renderer, command, structFields(result), "Uploaded: "+output.TerminalText(result.RemotePath))
}
output.AbortProgress(progress)
case "get":
+ if isShare {
+ return runShareGet(commandCtx, renderer, stdin, stderr, deps, positionals, *overwrite, *quiet, *jsonMode)
+ }
var result GetResult
progress := output.NewDownloadProgress(stderr, *quiet, interactiveWriter(stderr), time.Now)
if deps.Get == nil {
@@ -199,7 +214,7 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
}
if err == nil {
progress.Finished()
- return renderSuccess(renderer, command, structFields(result), "Downloaded: "+result.LocalPath)
+ return renderSuccess(renderer, command, structFields(result), "Downloaded: "+output.TerminalText(result.LocalPath))
}
output.AbortProgress(progress)
case "rm":
@@ -210,9 +225,9 @@ func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
result, err = deps.Delete(commandCtx, positionals[0], *recursive)
}
if err == nil {
- human := "Moved to recycle bin: " + result.RemotePath
+ human := "Moved to recycle bin: " + output.TerminalText(result.RemotePath)
if result.PendingReview {
- human = "Deletion submitted for review: " + result.RemotePath
+ human = "Deletion submitted for review: " + output.TerminalText(result.RemotePath)
}
return renderSuccess(renderer, command, structFields(result), human)
}
@@ -283,19 +298,157 @@ func requestedJSON(args []string) bool {
return false
}
-func validateArity(command string, args []string) error {
+func runShareList(ctx context.Context, renderer *output.Renderer, deps Dependencies, positionals []string) int {
+ if deps.OpenShare == nil {
+ return renderError(renderer, localError("share dependency unavailable"))
+ }
+ session, err := deps.OpenShare(ctx, positionals[0])
+ if err != nil {
+ return renderError(renderer, err)
+ }
+ root := session.Root()
+ var entries []ShareEntry
+ if root.Type == "file" {
+ if len(positionals) != 1 {
+ return renderError(renderer, usageError("a single-file share has no directory to list"))
+ }
+ entries = []ShareEntry{root}
+ } else {
+ directory := ""
+ if len(positionals) == 2 {
+ directory = positionals[1]
+ }
+ entries, err = session.List(ctx, directory)
+ if err != nil {
+ return renderError(renderer, err)
+ }
+ }
+ if entries == nil {
+ entries = make([]ShareEntry, 0)
+ }
+ sort.SliceStable(entries, func(i, j int) bool {
+ if entries[i].SharePath == entries[j].SharePath {
+ return entries[i].Name < entries[j].Name
+ }
+ return entries[i].SharePath < entries[j].SharePath
+ })
+ lines := make([]string, len(entries))
+ for index, entry := range entries {
+ lines[index] = fmt.Sprintf("%s\t%s\t%d", output.TerminalText(entry.Type), output.TerminalText(entry.SharePath), entry.Size)
+ }
+ return renderSuccess(renderer, "ls", map[string]any{"source": "share", "entries": entries}, strings.Join(lines, "\n"))
+}
+
+func runShareGet(ctx context.Context, renderer *output.Renderer, stdin io.Reader, stderr io.Writer, deps Dependencies, positionals []string, overwrite, quiet, jsonMode bool) int {
+ if deps.OpenShare == nil {
+ return renderError(renderer, localError("share dependency unavailable"))
+ }
+ session, err := deps.OpenShare(ctx, positionals[0])
+ if err != nil {
+ return renderError(renderer, err)
+ }
+ root := session.Root()
+ paths := append([]string(nil), positionals[2:]...)
+ if root.Type == "file" {
+ if len(paths) != 0 {
+ return renderError(renderer, usageError("a single-file share does not accept file selections"))
+ }
+ if root.SharePath == "" {
+ return renderError(renderer, localError("share file path unavailable"))
+ }
+ paths = []string{root.SharePath}
+ } else if len(paths) == 0 {
+ if jsonMode {
+ return renderError(renderer, usageError("shared file paths are required with --json"))
+ }
+ inputFile, inputOK := stdin.(*os.File)
+ outputFile, outputOK := stderr.(*os.File)
+ if !inputOK || !outputOK {
+ return renderError(renderer, usageError("interactive share selection requires a terminal; provide file paths explicitly"))
+ }
+ selected, selectErr := picker.Run(ctx, sharePickerSource{session: session}, picker.Options{Input: inputFile, Output: outputFile, Title: "选择要下载的分享文件"})
+ if selectErr != nil {
+ if errors.Is(selectErr, picker.ErrCanceled) || errors.Is(selectErr, context.Canceled) || errors.Is(selectErr, context.DeadlineExceeded) {
+ return renderError(renderer, apperr.Wrap(apperr.Interrupted, "get", "interrupted", selectErr))
+ }
+ if errors.Is(selectErr, picker.ErrNotTerminal) {
+ return renderError(renderer, usageError("interactive share selection requires a terminal; provide file paths explicitly"))
+ }
+ return renderError(renderer, preserveCLIError("select shared files", selectErr))
+ }
+ if len(selected) == 0 {
+ return renderError(renderer, usageError("no shared files selected"))
+ }
+ paths = make([]string, len(selected))
+ for index, entry := range selected {
+ paths[index] = entry.Path
+ }
+ }
+
+ progress := output.NewDownloadProgress(stderr, quiet, interactiveWriter(stderr), time.Now)
+ results, err := session.Download(ctx, paths, positionals[1], overwrite, progress)
+ if err != nil {
+ output.AbortProgress(progress)
+ return renderError(renderer, err)
+ }
+ progress.Finished()
+ if results == nil {
+ results = make([]ShareGetResult, 0)
+ }
+ lines := make([]string, len(results))
+ for index, result := range results {
+ lines[index] = "Downloaded: " + output.TerminalText(result.LocalPath)
+ }
+ return renderSuccess(renderer, "get", map[string]any{"source": "share", "downloads": results}, strings.Join(lines, "\n"))
+}
+
+type sharePickerSource struct{ session ShareSession }
+
+func (source sharePickerSource) List(ctx context.Context, relativeDir string) ([]picker.Entry, error) {
+ entries, err := source.session.List(ctx, relativeDir)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]picker.Entry, len(entries))
+ for index, entry := range entries {
+ entryType := picker.EntryFile
+ if entry.Type == "directory" || entry.Type == "folder" {
+ entryType = picker.EntryDirectory
+ }
+ result[index] = picker.Entry{ID: entry.SharePath, Path: entry.SharePath, Name: entry.Name, Type: entryType, Size: entry.Size}
+ }
+ return result, nil
+}
+
+func preserveCLIError(operation string, err error) error {
+ var appErr *apperr.Error
+ if errors.As(err, &appErr) {
+ return err
+ }
+ return apperr.Wrap(apperr.Local, operation, "local operation failed", err)
+}
+
+func validateArity(command string, args []string, shareMode bool) error {
valid := false
switch command {
case "login", "status", "logout", "version":
valid = len(args) == 0
case "ls":
- valid = len(args) <= 1
+ if shareMode {
+ valid = len(args) == 1 || len(args) == 2
+ } else {
+ valid = len(args) <= 1
+ }
case "mkdir":
valid = len(args) == 1
case "put":
valid = len(args) == 2
case "get":
- valid = len(args) == 2
+ if shareMode {
+ valid = len(args) >= 2
+ } else {
+ valid = len(args) == 2
+ }
case "rm":
valid = len(args) == 1
}
diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go
index a82ec46..0285d3c 100644
--- a/internal/cli/run_test.go
+++ b/internal/cli/run_test.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
+ "reflect"
"strings"
"syscall"
"testing"
@@ -38,10 +39,40 @@ type calls struct {
progressSet bool
pasteLogin bool
loginInput bool
+ shareLink string
+ shareListDir string
+ sharePaths []string
+ shareLocal string
+}
+
+type shareSessionFake struct {
+ root cli.ShareEntry
+ entries []cli.ShareEntry
+ downloads []cli.ShareGetResult
+ calls *calls
+}
+
+func (s *shareSessionFake) Root() cli.ShareEntry { return s.root }
+
+func (s *shareSessionFake) List(_ context.Context, relativeDir string) ([]cli.ShareEntry, error) {
+ s.calls.shareListDir = relativeDir
+ return append([]cli.ShareEntry(nil), s.entries...), nil
+}
+
+func (s *shareSessionFake) Download(_ context.Context, paths []string, localDir string, overwrite bool, progress upload.Progress) ([]cli.ShareGetResult, error) {
+ s.calls.sharePaths = append([]string(nil), paths...)
+ s.calls.shareLocal = localDir
+ s.calls.overwrite = overwrite
+ if progress != nil {
+ progress.Started(9)
+ progress.Advanced(9)
+ progress.Finished()
+ }
+ return append([]cli.ShareGetResult(nil), s.downloads...), nil
}
func dependencies(c *calls) cli.Dependencies {
- return cli.Dependencies{
+ deps := cli.Dependencies{
Login: func(ctx context.Context, input io.Reader, notice io.Writer, paste bool) error {
_, _ = io.WriteString(notice, "Authorize at https://disk.example.invalid/oauth\n")
c.loginNotice = "written"
@@ -91,6 +122,117 @@ func dependencies(c *calls) cli.Dependencies {
return nil
},
}
+ deps.OpenShare = func(_ context.Context, link string) (cli.ShareSession, error) {
+ c.shareLink = link
+ return &shareSessionFake{
+ root: cli.ShareEntry{Name: "shared-root", SharePath: "", Type: "directory", Size: -1},
+ entries: []cli.ShareEntry{{Name: "a.tsv", SharePath: "数据/a.tsv", Type: "file", Size: 9}},
+ downloads: []cli.ShareGetResult{{SharePath: "数据/a.tsv", Revision: "r1", LocalPath: "/tmp/out/数据/a.tsv", Size: 9}},
+ calls: c,
+ }, nil
+ }
+ return deps
+}
+
+func TestRunShareListAndExplicitDownloadJSON(t *testing.T) {
+ const link = "https://disk.pku.edu.cn/link/AA-ShareCapabilityMarker"
+
+ t.Run("list share directory", func(t *testing.T) {
+ c := new(calls)
+ code, stdout, stderr := run(t, []string{"ls", "--share", link, "数据", "--json"}, dependencies(c))
+ if code != 0 || stderr != "" {
+ t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout, stderr)
+ }
+ got := decodeOne(t, stdout)
+ entries, _ := got["entries"].([]any)
+ if c.shareLink != link || c.shareListDir != "数据" || len(entries) != 1 {
+ t.Fatalf("calls=%+v envelope=%v", c, got)
+ }
+ entry := entries[0].(map[string]any)
+ if entry["share_path"] != "数据/a.tsv" || entry["type"] != "file" || entry["size"] != float64(9) {
+ t.Fatalf("entry=%v", entry)
+ }
+ if strings.Contains(stdout, "ShareCapabilityMarker") {
+ t.Fatalf("share capability leaked to stdout: %q", stdout)
+ }
+ })
+
+ t.Run("download explicit shared files", func(t *testing.T) {
+ c := new(calls)
+ code, stdout, stderr := run(t, []string{"get", "--share", link, "/tmp/out", "数据/a.tsv", "--overwrite", "--quiet", "--json"}, dependencies(c))
+ if code != 0 || stderr != "" {
+ t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout, stderr)
+ }
+ got := decodeOne(t, stdout)
+ downloads, _ := got["downloads"].([]any)
+ if c.shareLink != link || c.shareLocal != "/tmp/out" || !c.overwrite || !reflect.DeepEqual(c.sharePaths, []string{"数据/a.tsv"}) || len(downloads) != 1 {
+ t.Fatalf("calls=%+v envelope=%v", c, got)
+ }
+ result := downloads[0].(map[string]any)
+ if result["share_path"] != "数据/a.tsv" || result["local_path"] != "/tmp/out/数据/a.tsv" || result["size"] != float64(9) {
+ t.Fatalf("download=%v", result)
+ }
+ if strings.Contains(stdout, "ShareCapabilityMarker") {
+ t.Fatalf("share capability leaked to stdout: %q", stdout)
+ }
+ })
+}
+
+func TestRunShareGetNonInteractiveSelectionRules(t *testing.T) {
+ const link = "https://disk.pku.edu.cn/link/AA-ShareCapabilityMarker"
+
+ t.Run("directory share with JSON requires paths", func(t *testing.T) {
+ code, stdout, stderr := run(t, []string{"get", "--share", link, "/tmp/out", "--json"}, dependencies(new(calls)))
+ if code != 2 || stderr != "" {
+ t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout, stderr)
+ }
+ got := decodeOne(t, stdout)
+ if got["category"] != "usage" || strings.Contains(stdout, "ShareCapabilityMarker") {
+ t.Fatalf("envelope=%v", got)
+ }
+ })
+
+ t.Run("single file share downloads without picker", func(t *testing.T) {
+ c := new(calls)
+ deps := dependencies(c)
+ deps.OpenShare = func(_ context.Context, got string) (cli.ShareSession, error) {
+ c.shareLink = got
+ return &shareSessionFake{
+ root: cli.ShareEntry{Name: "single.tsv", SharePath: "single.tsv", Type: "file", Size: 9},
+ downloads: []cli.ShareGetResult{{SharePath: "single.tsv", LocalPath: "/tmp/out/single.tsv", Size: 9}},
+ calls: c,
+ }, nil
+ }
+ code, stdout, stderr := run(t, []string{"get", "--share", link, "/tmp/out", "--quiet", "--json"}, deps)
+ if code != 0 || stderr != "" || !reflect.DeepEqual(c.sharePaths, []string{"single.tsv"}) {
+ t.Fatalf("code=%d calls=%+v stdout=%q stderr=%q", code, c, stdout, stderr)
+ }
+ })
+}
+
+func TestRunShareHumanOutputEscapesUntrustedNames(t *testing.T) {
+ const link = "https://disk.pku.edu.cn/link/AA-ShareCapabilityMarker"
+ c := new(calls)
+ deps := dependencies(c)
+ deps.OpenShare = func(context.Context, string) (cli.ShareSession, error) {
+ return &shareSessionFake{
+ root: cli.ShareEntry{Name: "root", Type: "directory", Size: -1},
+ entries: []cli.ShareEntry{{Name: "unsafe", SharePath: "报告\x1b]8;;https://evil.invalid\a.tsv\nnext", Type: "file", Size: 9}},
+ calls: c,
+ }, nil
+ }
+ code, stdout, stderr := run(t, []string{"ls", "--share", link}, deps)
+ if code != 0 || stderr != "" {
+ t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout, stderr)
+ }
+ if strings.ContainsAny(stdout, "\x1b\a") || strings.Count(stdout, "\n") != 1 {
+ t.Fatalf("unsafe terminal output: %q", stdout)
+ }
+ for _, want := range []string{`\x1B`, `\x07`, `\x0A`} {
+ if !strings.Contains(stdout, want) {
+ t.Fatalf("stdout=%q missing %q", stdout, want)
+ }
+ }
}
func remaining(ctx context.Context) time.Duration {
diff --git a/internal/cli/types.go b/internal/cli/types.go
index dcffd0f..341c378 100644
--- a/internal/cli/types.go
+++ b/internal/cli/types.go
@@ -53,14 +53,40 @@ type DeleteResult struct {
PendingReview bool `json:"pending_review"`
}
+// ShareEntry is one file or directory visible below a share root. SharePath
+// is relative to that root and never contains the share URL or link ID.
+type ShareEntry struct {
+ Name string `json:"name"`
+ SharePath string `json:"share_path"`
+ Type string `json:"type"`
+ Size int64 `json:"size"`
+}
+
+// ShareGetResult describes one file downloaded from a share link.
+type ShareGetResult struct {
+ SharePath string `json:"share_path"`
+ Revision string `json:"revision,omitempty"`
+ LocalPath string `json:"local_path"`
+ Size int64 `json:"size"`
+}
+
+// ShareSession is an already-authorized public or organization-scoped share.
+// Implementations keep all link credentials private and in memory.
+type ShareSession interface {
+ Root() ShareEntry
+ List(context.Context, string) ([]ShareEntry, error)
+ Download(context.Context, []string, string, bool, upload.Progress) ([]ShareGetResult, error)
+}
+
// Dependencies contains the external command operations.
type Dependencies struct {
- Login func(context.Context, io.Reader, io.Writer, bool) error
- Status func(context.Context) (StatusResult, error)
- List func(context.Context, string) ([]ItemResult, error)
- Mkdir func(context.Context, string, bool) (ItemResult, error)
- Put func(context.Context, string, string, bool, upload.Progress) (PutResult, error)
- Get func(context.Context, string, string, bool, upload.Progress) (GetResult, error)
- Delete func(context.Context, string, bool) (DeleteResult, error)
- Logout func(context.Context, bool) error
+ Login func(context.Context, io.Reader, io.Writer, bool) error
+ Status func(context.Context) (StatusResult, error)
+ List func(context.Context, string) ([]ItemResult, error)
+ Mkdir func(context.Context, string, bool) (ItemResult, error)
+ Put func(context.Context, string, string, bool, upload.Progress) (PutResult, error)
+ Get func(context.Context, string, string, bool, upload.Progress) (GetResult, error)
+ OpenShare func(context.Context, string) (ShareSession, error)
+ Delete func(context.Context, string, bool) (DeleteResult, error)
+ Logout func(context.Context, bool) error
}
diff --git a/internal/download/downloader.go b/internal/download/downloader.go
index 572d48a..525c83a 100644
--- a/internal/download/downloader.go
+++ b/internal/download/downloader.go
@@ -59,8 +59,7 @@ func (d *Downloader) Get(ctx context.Context, item anyshare.Item, localPath stri
if d == nil || d.API == nil {
return Result{}, downloadError(apperr.Local, "configure download", errors.New("missing download dependency"))
}
- docID := itemIdentity(item)
- if docID == "" || item.Type != "file" || item.Rev == "" || item.Size < 0 {
+ if err := validateDownloadItem(item); err != nil {
return Result{}, downloadError(apperr.Remote, "select download", errors.New("remote path is not an exact file revision"))
}
abs, err := filepath.Abs(localPath)
@@ -71,6 +70,33 @@ func (d *Downloader) Get(ctx context.Context, item anyshare.Item, localPath stri
if err != nil {
return Result{}, downloadError(apperr.Local, "inspect download destination", err)
}
+ return d.getToTarget(ctx, item, abs, target, progress)
+}
+
+// GetAt downloads item below a descriptor-bound destination root. relativePath
+// is a normalized relative file path; missing parent directories are created
+// mode 0700 without following symbolic links.
+func (d *Downloader) GetAt(ctx context.Context, item anyshare.Item, root *DestinationRoot, relativePath string, overwrite bool, progress Progress) (Result, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if d == nil || d.API == nil {
+ return Result{}, downloadError(apperr.Local, "configure download", errors.New("missing download dependency"))
+ }
+ if err := validateDownloadItem(item); err != nil {
+ return Result{}, downloadError(apperr.Remote, "select download", errors.New("remote path is not an exact file revision"))
+ }
+ target, displayPath, err := root.openTarget(relativePath, overwrite)
+ if err != nil {
+ return Result{}, downloadError(apperr.Local, "inspect download destination", err)
+ }
+ return d.getToTarget(ctx, item, displayPath, target, progress)
+}
+
+func (d *Downloader) getToTarget(ctx context.Context, item anyshare.Item, displayPath string, target *downloadTarget, progress Progress) (result Result, returnErr error) {
+ if target == nil {
+ return Result{}, downloadError(apperr.Local, "inspect download destination", errors.New("missing download target"))
+ }
committed := false
defer func() {
if closeErr := target.Close(); !committed && returnErr == nil && closeErr != nil {
@@ -79,7 +105,7 @@ func (d *Downloader) Get(ctx context.Context, item anyshare.Item, localPath stri
}
}()
- metadata, err := d.API.FileMetadata(ctx, docID, item.Rev)
+ metadata, err := d.API.FileMetadata(ctx, itemIdentity(item), item.Rev)
if err != nil {
return Result{}, classifyDownloadError("read download metadata", err)
}
@@ -95,7 +121,7 @@ func (d *Downloader) Get(ctx context.Context, item anyshare.Item, localPath stri
saveName = item.Name
}
if saveName == "" {
- saveName = filepath.Base(abs)
+ saveName = filepath.Base(displayPath)
}
stream, err := d.openStream(ctx, metadata, saveName)
if err != nil {
@@ -141,7 +167,14 @@ func (d *Downloader) Get(ctx context.Context, item anyshare.Item, localPath stri
if progress != nil {
progress.Finished()
}
- return Result{LocalPath: abs, RemoteID: itemIdentity(metadata), Revision: metadata.Rev, Size: metadata.Size}, nil
+ return Result{LocalPath: displayPath, RemoteID: itemIdentity(metadata), Revision: metadata.Rev, Size: metadata.Size}, nil
+}
+
+func validateDownloadItem(item anyshare.Item) error {
+ if itemIdentity(item) == "" || item.Type != "file" || item.Rev == "" || item.Size < 0 {
+ return errors.New("remote path is not an exact file revision")
+ }
+ return nil
}
func (d *Downloader) openStream(ctx context.Context, item anyshare.Item, saveName string) (anyshare.DownloadStream, error) {
diff --git a/internal/download/downloader_test.go b/internal/download/downloader_test.go
index 29683de..9e742ee 100644
--- a/internal/download/downloader_test.go
+++ b/internal/download/downloader_test.go
@@ -62,6 +62,142 @@ func TestGetVerifiesMetadataAndAtomicallyWritesFile(t *testing.T) {
assertDirectoryEntries(t, filepath.Dir(destination), filepath.Base(destination))
}
+func TestGetAtCreatesRelativeDirectoriesAndKeepsVerifiedLayout(t *testing.T) {
+ const contents = "nested verified download"
+ item := fileItem(contents)
+ api := &downloadAPIFake{
+ metadata: item,
+ authorizations: []anyshare.SignedRequest{{Method: "GET", URL: "https://objects.invalid/nested"}},
+ streams: []streamResult{{stream: anyshare.DownloadStream{Body: io.NopCloser(strings.NewReader(contents)), ContentLength: int64(len(contents))}}},
+ }
+ rootPath := t.TempDir()
+ root, err := download.OpenDestinationRoot(rootPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer root.Close()
+
+ result, err := download.NewDownloader(api).GetAt(context.Background(), item, root, "一级目录/数据/file.tsv", false, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := filepath.Join(rootPath, "一级目录", "数据", "file.tsv")
+ if result.LocalPath != want {
+ t.Fatalf("LocalPath = %q, want %q", result.LocalPath, want)
+ }
+ data, err := os.ReadFile(want)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != contents {
+ t.Fatalf("contents = %q", data)
+ }
+ for _, directory := range []string{filepath.Join(rootPath, "一级目录"), filepath.Join(rootPath, "一级目录", "数据")} {
+ info, err := os.Stat(directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0o700 {
+ t.Fatalf("mode(%s) = %o, want 0700", directory, info.Mode().Perm())
+ }
+ }
+ info, err := os.Stat(want)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0o600 {
+ t.Fatalf("file mode = %o, want 0600", info.Mode().Perm())
+ }
+}
+
+func TestGetAtRejectsUnsafeRelativePathsBeforeNetwork(t *testing.T) {
+ item := fileItem("unused")
+ root, err := download.OpenDestinationRoot(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer root.Close()
+ for _, relative := range []string{"", ".", "../escape", "dir/../escape", "/absolute", "dir//file", "dir/./file", "dir/\x00file"} {
+ t.Run(strings.ReplaceAll(relative, "/", "_"), func(t *testing.T) {
+ api := &downloadAPIFake{metadata: item}
+ _, err := download.NewDownloader(api).GetAt(context.Background(), item, root, relative, false, nil)
+ var appErr *apperr.Error
+ if !errors.As(err, &appErr) || appErr.Category != apperr.Local {
+ t.Fatalf("err = %v, want local", err)
+ }
+ if len(api.authorizationCalls) != 0 {
+ t.Fatalf("network authorization calls = %d", len(api.authorizationCalls))
+ }
+ })
+ }
+}
+
+func TestGetAtRejectsSymlinkDirectoryWithoutWritingOutsideRoot(t *testing.T) {
+ const contents = "must stay inside root"
+ item := fileItem(contents)
+ outside := t.TempDir()
+ rootPath := t.TempDir()
+ if err := os.Symlink(outside, filepath.Join(rootPath, "redirect")); err != nil {
+ t.Fatal(err)
+ }
+ root, err := download.OpenDestinationRoot(rootPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer root.Close()
+ api := &downloadAPIFake{metadata: item}
+
+ _, err = download.NewDownloader(api).GetAt(context.Background(), item, root, "redirect/file.tsv", false, nil)
+ var appErr *apperr.Error
+ if !errors.As(err, &appErr) || appErr.Category != apperr.Local {
+ t.Fatalf("err = %v, want local", err)
+ }
+ if _, err := os.Stat(filepath.Join(outside, "file.tsv")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("outside destination err = %v, want not exist", err)
+ }
+ if len(api.authorizationCalls) != 0 {
+ t.Fatalf("network authorization calls = %d", len(api.authorizationCalls))
+ }
+}
+
+func TestDestinationRootStaysBoundWhenPathIsReplaced(t *testing.T) {
+ const contents = "bound directory contents"
+ item := fileItem(contents)
+ api := &downloadAPIFake{
+ metadata: item,
+ authorizations: []anyshare.SignedRequest{{Method: "GET", URL: "https://objects.invalid/bound"}},
+ streams: []streamResult{{stream: anyshare.DownloadStream{Body: io.NopCloser(strings.NewReader(contents)), ContentLength: int64(len(contents))}}},
+ }
+ parent := t.TempDir()
+ rootPath := filepath.Join(parent, "downloads")
+ if err := os.Mkdir(rootPath, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ root, err := download.OpenDestinationRoot(rootPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer root.Close()
+ heldPath := filepath.Join(parent, "held")
+ if err := os.Rename(rootPath, heldPath); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(rootPath, 0o700); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := download.NewDownloader(api).GetAt(context.Background(), item, root, "nested/file.tsv", false, nil); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(filepath.Join(heldPath, "nested", "file.tsv"))
+ if err != nil || string(data) != contents {
+ t.Fatalf("held contents = %q err=%v", data, err)
+ }
+ if _, err := os.Stat(filepath.Join(rootPath, "nested", "file.tsv")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("replacement destination err = %v, want not exist", err)
+ }
+}
+
func TestGetRefusesExistingDestinationWithoutNetwork(t *testing.T) {
const original = "keep original"
destination := filepath.Join(t.TempDir(), "existing.bin")
diff --git a/internal/download/local_linux_amd64.go b/internal/download/local_linux_amd64.go
index 6926271..f49a23a 100644
--- a/internal/download/local_linux_amd64.go
+++ b/internal/download/local_linux_amd64.go
@@ -37,6 +37,7 @@ var (
type localLinuxOps struct {
open func(string, int, uint32) (int, error)
openat func(int, string, int, uint32) (int, error)
+ mkdirat func(int, string, uint32) error
fstat func(int, *syscall.Stat_t) error
close func(int) error
fsync func(int) error
@@ -48,6 +49,7 @@ type localLinuxOps struct {
var hostLocalLinuxOps = localLinuxOps{
open: syscall.Open,
openat: syscall.Openat,
+ mkdirat: syscall.Mkdirat,
fstat: syscall.Fstat,
close: syscall.Close,
fsync: syscall.Fsync,
@@ -113,6 +115,50 @@ type downloadTarget struct {
closed bool
}
+// DestinationRoot binds an existing local directory for a group of relative
+// downloads. The held descriptor remains authoritative even if a pathname is
+// renamed or replaced while the batch is running.
+type DestinationRoot struct {
+ directory *ownedLocalFD
+ path string
+ ops localLinuxOps
+ closed bool
+}
+
+// OpenDestinationRoot opens an existing directory without following symbolic
+// links in any path component.
+func OpenDestinationRoot(path string) (*DestinationRoot, error) {
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return nil, err
+ }
+ abs = filepath.Clean(abs)
+ directory, err := openBoundDirectory(abs, hostLocalLinuxOps)
+ if err != nil {
+ return nil, err
+ }
+ return &DestinationRoot{directory: directory, path: abs, ops: hostLocalLinuxOps}, nil
+}
+
+// Close releases the directory descriptor. It is idempotent.
+func (r *DestinationRoot) Close() error {
+ if r == nil || r.closed {
+ return nil
+ }
+ r.closed = true
+ return r.directory.Close()
+}
+
+// Preflight validates one relative destination and creates only its missing
+// parent directories. It performs no network access and publishes no file.
+func (r *DestinationRoot) Preflight(relative string, overwrite bool) error {
+ target, _, err := r.openTarget(relative, overwrite)
+ if err != nil {
+ return err
+ }
+ return target.Close()
+}
+
func openDownloadTarget(path string, overwrite bool) (*downloadTarget, error) {
return openDownloadTargetWithOps(path, overwrite, hostLocalLinuxOps, rand.Reader)
}
@@ -129,6 +175,16 @@ func openDownloadTargetWithOps(path string, overwrite bool, ops localLinuxOps, r
if err != nil {
return nil, err
}
+ return newDownloadTarget(directory, name, overwrite, ops, random)
+}
+
+func newDownloadTarget(directory *ownedLocalFD, name string, overwrite bool, ops localLinuxOps, random io.Reader) (*downloadTarget, error) {
+ if directory == nil || directory.fd < 0 || name == "" || name == "." || name == ".." || strings.ContainsRune(name, filepath.Separator) || strings.ContainsRune(name, '\x00') {
+ if directory != nil {
+ _ = directory.Close()
+ }
+ return nil, errUnsafeLocalEntry
+ }
target := &downloadTarget{directory: directory, name: name, overwrite: overwrite, ops: ops, random: random}
existing, err := target.openEntry(name)
if errors.Is(err, syscall.ENOENT) {
@@ -152,6 +208,70 @@ func openDownloadTargetWithOps(path string, overwrite bool, ops localLinuxOps, r
return target, nil
}
+func (r *DestinationRoot) openTarget(relative string, overwrite bool) (*downloadTarget, string, error) {
+ components, err := safeRelativeComponents(relative)
+ if err != nil || r == nil || r.closed || r.directory == nil || r.directory.fd < 0 {
+ return nil, "", errUnsafeLocalEntry
+ }
+ currentFD, err := r.ops.openat(r.directory.fd, ".", syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
+ if err != nil {
+ return nil, "", err
+ }
+ current := newOwnedLocalFD(currentFD, r.ops.close)
+ for _, component := range components[:len(components)-1] {
+ nextFD, openErr := r.ops.openat(current.fd, component, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
+ if errors.Is(openErr, syscall.ENOENT) {
+ if r.ops.mkdirat == nil {
+ _ = current.Close()
+ return nil, "", errUnsafeLocalEntry
+ }
+ mkdirErr := r.ops.mkdirat(current.fd, component, 0o700)
+ if mkdirErr != nil && !errors.Is(mkdirErr, syscall.EEXIST) {
+ _ = current.Close()
+ return nil, "", mkdirErr
+ }
+ nextFD, openErr = r.ops.openat(current.fd, component, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
+ }
+ if openErr != nil {
+ _ = current.Close()
+ return nil, "", openErr
+ }
+ next := newOwnedLocalFD(nextFD, r.ops.close)
+ var stat syscall.Stat_t
+ if err := r.ops.fstat(next.fd, &stat); err != nil || stat.Mode&syscall.S_IFMT != syscall.S_IFDIR {
+ _ = next.Close()
+ _ = current.Close()
+ if err != nil {
+ return nil, "", err
+ }
+ return nil, "", errUnsafeLocalEntry
+ }
+ if err := current.Close(); err != nil {
+ _ = next.Close()
+ return nil, "", err
+ }
+ current = next
+ }
+ target, err := newDownloadTarget(current, components[len(components)-1], overwrite, r.ops, rand.Reader)
+ if err != nil {
+ return nil, "", err
+ }
+ return target, filepath.Join(append([]string{r.path}, components...)...), nil
+}
+
+func safeRelativeComponents(relative string) ([]string, error) {
+ if relative == "" || filepath.IsAbs(relative) || filepath.Clean(relative) != relative || strings.ContainsRune(relative, '\x00') || strings.Contains(relative, "//") {
+ return nil, errUnsafeLocalEntry
+ }
+ components := strings.Split(relative, string(filepath.Separator))
+ for _, component := range components {
+ if component == "" || component == "." || component == ".." {
+ return nil, errUnsafeLocalEntry
+ }
+ }
+ return components, nil
+}
+
func (t *downloadTarget) Close() error {
if t == nil || t.closed {
return nil
diff --git a/internal/integration/full_flow_test.go b/internal/integration/full_flow_test.go
index 2e7bc93..edd4541 100644
--- a/internal/integration/full_flow_test.go
+++ b/internal/integration/full_flow_test.go
@@ -34,6 +34,7 @@ import (
"github.com/Findddx/pku-drive-cli/internal/httpx"
"github.com/Findddx/pku-drive-cli/internal/oauth"
"github.com/Findddx/pku-drive-cli/internal/remote"
+ "github.com/Findddx/pku-drive-cli/internal/sharelink"
"github.com/Findddx/pku-drive-cli/internal/upload"
"github.com/Findddx/pku-drive-cli/internal/version"
)
@@ -43,6 +44,20 @@ const (
fixtureRefreshedAccess = "fixture-access-refreshed"
fixtureRefresh = "fixture-refresh"
fixtureSecret = "fixture-secret"
+ fixtureShareToken = "fake-share-token-marker"
+
+ fixturePublicLinkID = "FakePublicLinkMarker"
+ fixtureRealnameLinkID = "FakeRealnameLinkMarker"
+ fixturePasswordLinkID = "FakePasswordLinkMarker"
+ fixtureMobileLinkID = "FakeMobileLinkMarker"
+ fixtureRedirectLinkID = "FakeRedirectLinkMarker"
+
+ fixturePublicRootID = "gns://fixture-share-public-root"
+ fixturePublicDirID = "gns://fixture-share-public-dir"
+ fixturePublicFileID = "gns://fixture-share-public-file"
+ fixturePublicTopID = "gns://fixture-share-public-top"
+ fixtureRealRootID = "gns://fixture-share-real-root"
+ fixtureRealFileID = "gns://fixture-share-real-file"
)
var fixtureNow = time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)
@@ -124,6 +139,7 @@ func newFixture(t *testing.T) *fixture {
activeAccess: fixtureAccess,
}
f.nodes["gns://library"] = &fixtureNode{ID: "gns://library", Name: "个人文档", Type: "directory", Rev: "library-r1", Path: "/个人文档", Size: -1}
+ f.addShareFixtures()
f.objects = httptest.NewTLSServer(http.HandlerFunc(f.handleObject))
f.control = httptest.NewTLSServer(http.HandlerFunc(f.handleControl))
t.Cleanup(f.control.Close)
@@ -131,9 +147,134 @@ func newFixture(t *testing.T) *fixture {
return f
}
+func (f *fixture) addShareFixtures() {
+ addFile := func(id, parentID, name, path, revision string, data []byte) {
+ evidence := contentEvidence(name, data, fixtureNow.UnixNano()/1000, false)
+ f.nodes[id] = &fixtureNode{
+ ID: id, ParentID: parentID, Name: name, Type: "file", Rev: revision, Path: path,
+ Size: evidence.Size, ClientMtimeUS: evidence.ClientMtimeUS, MD5: evidence.MD5,
+ SliceMD5: evidence.SliceMD5, CRC32: evidence.CRC32,
+ }
+ f.files[path] = append([]byte(nil), data...)
+ }
+
+ f.nodes[fixturePublicRootID] = &fixtureNode{ID: fixturePublicRootID, Name: "公开分享", Type: "directory", Rev: "share-public-root-r1", Path: "/fixture/share/public", Size: -1}
+ f.nodes[fixturePublicDirID] = &fixtureNode{ID: fixturePublicDirID, ParentID: fixturePublicRootID, Name: "sub", Type: "directory", Rev: "share-public-dir-r1", Path: "/fixture/share/public/sub", Size: -1}
+ addFile(fixturePublicFileID, fixturePublicDirID, "nested.txt", "/fixture/share/public/sub/nested.txt", "share-public-file-r1", []byte("fake anonymous share payload\n"))
+ addFile(fixturePublicTopID, fixturePublicRootID, "top.txt", "/fixture/share/public/top.txt", "share-public-top-r1", []byte("fake top-level share payload\n"))
+
+ f.nodes[fixtureRealRootID] = &fixtureNode{ID: fixtureRealRootID, Name: "组织分享", Type: "directory", Rev: "share-real-root-r1", Path: "/fixture/share/real", Size: -1}
+ addFile(fixtureRealFileID, fixtureRealRootID, "member.txt", "/fixture/share/real/member.txt", "share-real-file-r1", []byte("fake organization share payload\n"))
+}
+
+type fixtureShareInfo struct {
+ linkType, itemType, title, rootID string
+ passwordRequired, verifyMobile bool
+}
+
+func lookupFixtureShare(linkID string) (fixtureShareInfo, bool) {
+ switch linkID {
+ case fixturePublicLinkID:
+ return fixtureShareInfo{linkType: "anonymous", itemType: "folder", title: "公开分享", rootID: fixturePublicRootID}, true
+ case fixtureRealnameLinkID:
+ return fixtureShareInfo{linkType: "realname", itemType: "folder", title: "组织分享", rootID: fixtureRealRootID}, true
+ case fixturePasswordLinkID:
+ return fixtureShareInfo{linkType: "anonymous", itemType: "folder", title: "口令分享", rootID: fixturePublicRootID, passwordRequired: true}, true
+ case fixtureMobileLinkID:
+ return fixtureShareInfo{linkType: "anonymous", itemType: "folder", title: "手机验证分享", rootID: fixturePublicRootID, verifyMobile: true}, true
+ case fixtureRedirectLinkID:
+ return fixtureShareInfo{linkType: "anonymous", itemType: "folder", title: "重定向分享", rootID: fixturePublicRootID}, true
+ default:
+ return fixtureShareInfo{}, false
+ }
+}
+
+func (f *fixture) startShareLanding(w http.ResponseWriter, r *http.Request) {
+ linkID := strings.TrimPrefix(r.URL.Path, "/link/")
+ if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" {
+ f.t.Errorf("initial shared-link request carried credentials")
+ http.Error(w, "unexpected credentials", http.StatusUnauthorized)
+ return
+ }
+ if linkID == fixtureRedirectLinkID {
+ w.Header().Set("Location", f.objects.URL+"/redirect-target")
+ w.WriteHeader(http.StatusFound)
+ return
+ }
+ info, ok := lookupFixtureShare(linkID)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ // The legacy AnyShare cookie name contains a colon, so write the fixture
+ // header exactly as the server does instead of using http.SetCookie.
+ w.Header().Add("Set-Cookie", "link_token:"+linkID+"="+fixtureShareToken+"; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Lax")
+ query := url.Values{
+ "type": {info.linkType},
+ "item_type": {info.itemType},
+ "title": {info.title},
+ "password_required": {strconv.FormatBool(info.passwordRequired)},
+ "verify_mobile": {strconv.FormatBool(info.verifyMobile)},
+ }
+ w.Header().Set("Location", "/anyshare/link/"+linkID+"?"+query.Encode())
+ w.WriteHeader(http.StatusFound)
+}
+
+func (f *fixture) finishShareLanding(w http.ResponseWriter, r *http.Request) {
+ linkID := strings.TrimPrefix(r.URL.Path, "/anyshare/link/")
+ if _, ok := lookupFixtureShare(linkID); !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Header.Get("Authorization") != "" || !hasFixtureShareCookie(r, linkID) {
+ f.t.Errorf("shared-link landing did not use its isolated cookie session")
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = io.WriteString(w, "
fixture share")
+}
+
+func (f *fixture) shareMetadata(w http.ResponseWriter, r *http.Request) {
+ linkID := strings.TrimPrefix(r.URL.Path, "/api/shared-link/v1/links/")
+ info, ok := lookupFixtureShare(linkID)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" {
+ f.t.Errorf("shared-link metadata request carried credentials: authorization-present=%v cookie-present=%v", r.Header.Get("Authorization") != "", r.Header.Get("Cookie") != "")
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ writeJSON(w, map[string]any{
+ "type": info.linkType, "id": linkID, "title": info.title, "expires_at": 0,
+ "password_required": info.passwordRequired, "verify_mobile": info.verifyMobile,
+ "item": map[string]any{
+ "belongs_to": "document", "id": info.rootID, "type": info.itemType, "name": info.title,
+ },
+ })
+}
+
+func hasFixtureShareCookie(r *http.Request, linkID string) bool {
+ want := "link_token:" + linkID + "=" + fixtureShareToken
+ for _, part := range strings.Split(r.Header.Get("Cookie"), ";") {
+ if strings.TrimSpace(part) == want {
+ return true
+ }
+ }
+ return false
+}
+
func (f *fixture) handleControl(w http.ResponseWriter, r *http.Request) {
f.recordCall(r.URL.Path)
switch {
+ case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/link/"):
+ f.startShareLanding(w, r)
+ case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/anyshare/link/"):
+ f.finishShareLanding(w, r)
+ case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/shared-link/v1/links/"):
+ f.shareMetadata(w, r)
case r.Method == http.MethodPost && r.URL.Path == "/oauth2/clients":
var request struct {
ClientName string `json:"client_name"`
@@ -235,8 +376,16 @@ func (f *fixture) handleControl(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, []map[string]any{{"id": "gns://library", "docid": "gns://library", "name": "个人文档", "path": "/个人文档", "type": "user_doc_lib", "size": -1}})
+ case r.Method == http.MethodGet && r.URL.Path == "/api/efast/v1/entry-item":
+ if !f.requireShareBearer(w, r) {
+ return
+ }
+ writeJSON(w, []map[string]any{{
+ "id": fixturePublicRootID, "docid": fixturePublicRootID, "name": "公开分享",
+ "path": "/fixture/share/public", "type": "folder", "rev": "share-public-root-r1", "size": -1,
+ }})
case strings.HasPrefix(r.URL.Path, "/api/efast/"):
- if !f.requireBearer(w, r) {
+ if !f.requireDocumentBearer(w, r) {
return
}
f.handleDocuments(w, r)
@@ -888,7 +1037,7 @@ func (f *fixture) handleObject(w http.ResponseWriter, r *http.Request) {
}
func (f *fixture) downloadObject(w http.ResponseWriter, r *http.Request, authorization string) {
- if authorization != "" || r.Header.Get("x-as-userid") != "" || r.URL.Query().Get("signature") != "fixture-download" {
+ if authorization != "" || r.Header.Get("Cookie") != "" || r.Header.Get("x-as-userid") != "" || r.URL.Query().Get("signature") != "fixture-download" {
http.Error(w, "bad signed download", http.StatusUnauthorized)
return
}
@@ -962,6 +1111,57 @@ func (f *fixture) requireBearer(w http.ResponseWriter, r *http.Request) bool {
return true
}
+func (f *fixture) requireShareBearer(w http.ResponseWriter, r *http.Request) bool {
+ if r.Header.Get("Authorization") != "Bearer "+fixtureShareToken || r.Header.Get("Cookie") != "" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return false
+ }
+ return true
+}
+
+func (f *fixture) requireDocumentBearer(w http.ResponseWriter, r *http.Request) bool {
+ docID := fixtureDocumentID(r)
+ if isFixturePublicDocument(docID) {
+ return f.requireShareBearer(w, r)
+ }
+ return f.requireBearer(w, r)
+}
+
+func fixtureDocumentID(r *http.Request) string {
+ const folderPrefix = "/api/efast/v1/folders/"
+ if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, folderPrefix) && strings.HasSuffix(r.URL.Path, "/sub_objects") {
+ raw := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, folderPrefix), "/sub_objects")
+ if decoded, err := url.PathUnescape(raw); err == nil {
+ return decoded
+ }
+ return raw
+ }
+ if r.Body == nil {
+ return ""
+ }
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ return ""
+ }
+ r.Body = io.NopCloser(bytes.NewReader(body))
+ var request struct {
+ DocID string `json:"docid"`
+ }
+ if json.Unmarshal(body, &request) != nil {
+ return ""
+ }
+ return request.DocID
+}
+
+func isFixturePublicDocument(docID string) bool {
+ switch docID {
+ case fixturePublicRootID, fixturePublicDirID, fixturePublicFileID, fixturePublicTopID:
+ return true
+ default:
+ return false
+ }
+}
+
func (f *fixture) recordCall(route string) {
f.mu.Lock()
f.calls[route]++
@@ -1308,6 +1508,188 @@ func TestCLIGetAndDelete(t *testing.T) {
assertListingIncludes(t, runJSON(t, r, []string{"ls", base, "--json"}, 0), pendingPath)
}
+func TestCLIShareLinkDownload(t *testing.T) {
+ t.Run("anonymous folder works without saved credentials", func(t *testing.T) {
+ f := newFixture(t)
+ r := newRuntime(t, f)
+ link := f.control.URL + "/link/" + fixturePublicLinkID
+
+ listing := runJSON(t, r, []string{"ls", "--share", link, "--json"}, 0)
+ if listing["source"] != "share" {
+ t.Fatalf("share listing=%v", listing)
+ }
+ entries, ok := listing["entries"].([]any)
+ if !ok || len(entries) != 2 {
+ t.Fatalf("share entries=%T %v", listing["entries"], listing["entries"])
+ }
+ wantFirstLevel := map[string]string{"sub": "directory", "top.txt": "file"}
+ for _, raw := range entries {
+ entry, ok := raw.(map[string]any)
+ if !ok {
+ t.Fatalf("share entry=%T %v", raw, raw)
+ }
+ path, _ := entry["share_path"].(string)
+ if entry["type"] != wantFirstLevel[path] || entry["name"] != path || strings.Contains(path, "nested.txt") {
+ t.Fatalf("first-level share entry=%v", entry)
+ }
+ }
+
+ destination := t.TempDir()
+ result := runJSON(t, r, []string{"get", "--share", link, destination, "sub/nested.txt", "--json"}, 0)
+ if result["source"] != "share" {
+ t.Fatalf("share download=%v", result)
+ }
+ downloads, ok := result["downloads"].([]any)
+ if !ok || len(downloads) != 1 {
+ t.Fatalf("share downloads=%T %v", result["downloads"], result["downloads"])
+ }
+ downloaded := downloads[0].(map[string]any)
+ localPath := filepath.Join(destination, "sub", "nested.txt")
+ if downloaded["share_path"] != "sub/nested.txt" || downloaded["local_path"] != localPath || downloaded["revision"] != "share-public-file-r1" {
+ t.Fatalf("share download result=%v", downloaded)
+ }
+ want := f.fileBytes("/fixture/share/public/sub/nested.txt")
+ if got, err := os.ReadFile(localPath); err != nil || !bytes.Equal(got, want) {
+ t.Fatalf("anonymous share bytes=%q err=%v want=%q", got, err, want)
+ }
+ if info, err := os.Stat(localPath); err != nil || info.Mode().Perm() != 0o600 {
+ t.Fatalf("anonymous share mode=%v err=%v want=0600", infoMode(info), err)
+ }
+ if info, err := os.Stat(filepath.Dir(localPath)); err != nil || info.Mode().Perm() != 0o700 {
+ t.Fatalf("anonymous share parent mode=%v err=%v want=0700", infoMode(info), err)
+ }
+
+ if got := f.callCount("/api/efast/v1/entry-item"); got != 2 {
+ t.Fatalf("anonymous entry-item calls=%d want=2", got)
+ }
+ if got := f.callCount(fixtureFolderRoute(fixturePublicRootID)); got != 2 {
+ t.Fatalf("anonymous root list calls=%d want=2", got)
+ }
+ if got := f.callCount(fixtureFolderRoute(fixturePublicDirID)); got != 1 {
+ t.Fatalf("anonymous nested list calls=%d want=1", got)
+ }
+ if got := f.callCount("/api/efast/v1/file/metadata"); got != 1 {
+ t.Fatalf("anonymous metadata calls=%d want=1", got)
+ }
+ if got := f.callCount("/api/efast/v1/file/osdownload"); got != 1 {
+ t.Fatalf("anonymous download authorization calls=%d want=1", got)
+ }
+ if got := f.callCount("object:/download"); got != 1 {
+ t.Fatalf("anonymous object calls=%d want=1", got)
+ }
+ })
+
+ t.Run("realname folder requires and then uses saved login", func(t *testing.T) {
+ f := newFixture(t)
+ r := newRuntime(t, f)
+ link := f.control.URL + "/link/" + fixtureRealnameLinkID
+ destination := t.TempDir()
+
+ unauthorized := runJSON(t, r, []string{"get", "--share", link, destination, "member.txt", "--json"}, 3)
+ if unauthorized["ok"] != false || unauthorized["category"] != "auth" {
+ t.Fatalf("realname without login=%v", unauthorized)
+ }
+ if got := f.callCountPrefix("/api/efast/"); got != 0 {
+ t.Fatalf("realname without login sent %d document requests", got)
+ }
+ if got := f.callCount("object:/download"); got != 0 {
+ t.Fatalf("realname without login sent %d object requests", got)
+ }
+
+ loginCLI(t, r)
+ result := runJSON(t, r, []string{"get", "--share", link, destination, "member.txt", "--json"}, 0)
+ downloads, ok := result["downloads"].([]any)
+ if result["source"] != "share" || !ok || len(downloads) != 1 {
+ t.Fatalf("realname download=%v", result)
+ }
+ downloaded := downloads[0].(map[string]any)
+ localPath := filepath.Join(destination, "member.txt")
+ if downloaded["share_path"] != "member.txt" || downloaded["local_path"] != localPath || downloaded["revision"] != "share-real-file-r1" {
+ t.Fatalf("realname download result=%v", downloaded)
+ }
+ want := f.fileBytes("/fixture/share/real/member.txt")
+ if got, err := os.ReadFile(localPath); err != nil || !bytes.Equal(got, want) {
+ t.Fatalf("realname share bytes=%q err=%v want=%q", got, err, want)
+ }
+ if info, err := os.Stat(localPath); err != nil || info.Mode().Perm() != 0o600 {
+ t.Fatalf("realname share mode=%v err=%v want=0600", infoMode(info), err)
+ }
+ if got := f.callCount("/api/efast/v1/entry-item"); got != 0 {
+ t.Fatalf("realname used anonymous entry endpoint %d times", got)
+ }
+ if got := f.callCount(fixtureFolderRoute(fixtureRealRootID)); got != 1 {
+ t.Fatalf("realname root list calls=%d want=1", got)
+ }
+ if got := f.callCount("/api/efast/v1/file/metadata"); got != 1 {
+ t.Fatalf("realname metadata calls=%d want=1", got)
+ }
+ if got := f.callCount("/api/efast/v1/file/osdownload"); got != 1 {
+ t.Fatalf("realname download authorization calls=%d want=1", got)
+ }
+ if got := f.callCount("object:/download"); got != 1 {
+ t.Fatalf("realname object calls=%d want=1", got)
+ }
+ })
+
+ for _, tc := range []struct {
+ name, linkID string
+ }{
+ {name: "password-protected public link", linkID: fixturePasswordLinkID},
+ {name: "mobile-verified public link", linkID: fixtureMobileLinkID},
+ } {
+ t.Run(tc.name+" stops before documents", func(t *testing.T) {
+ f := newFixture(t)
+ r := newRuntime(t, f)
+ link := f.control.URL + "/link/" + tc.linkID
+ result := runJSON(t, r, []string{"get", "--share", link, t.TempDir(), "top.txt", "--json"}, 3)
+ if result["ok"] != false || result["category"] != "auth" {
+ t.Fatalf("restricted share result=%v", result)
+ }
+ if got := f.callCountPrefix("/api/efast/"); got != 0 {
+ t.Fatalf("restricted share sent %d document requests", got)
+ }
+ if got := f.callCountPrefix("object:"); got != 0 {
+ t.Fatalf("restricted share sent %d object requests", got)
+ }
+ })
+ }
+
+ t.Run("other origin is rejected before network", func(t *testing.T) {
+ f := newFixture(t)
+ r := newRuntime(t, f)
+ result := runJSON(t, r, []string{"ls", "--share", "https://other.invalid/link/FakeOtherHostMarker", "--json"}, 2)
+ if result["ok"] != false || result["category"] != "usage" {
+ t.Fatalf("other-origin result=%v", result)
+ }
+ if got := f.callCountPrefix("/link/"); got != 0 {
+ t.Fatalf("other-origin link made %d control requests", got)
+ }
+ })
+
+ t.Run("cross-origin redirect is not followed", func(t *testing.T) {
+ f := newFixture(t)
+ r := newRuntime(t, f)
+ link := f.control.URL + "/link/" + fixtureRedirectLinkID
+ result := runJSON(t, r, []string{"ls", "--share", link, "--json"}, 5)
+ if result["ok"] != false || result["category"] != "network" {
+ t.Fatalf("cross-origin redirect result=%v", result)
+ }
+ if got := f.callCount("object:/redirect-target"); got != 0 {
+ t.Fatalf("cross-origin redirect target received %d requests", got)
+ }
+ if got := f.callCount("/api/shared-link/v1/links/" + fixtureRedirectLinkID); got != 1 {
+ t.Fatalf("cross-origin redirect metadata calls=%d want=1", got)
+ }
+ if got := f.callCountPrefix("/api/efast/"); got != 0 {
+ t.Fatalf("cross-origin redirect continued with %d document requests", got)
+ }
+ })
+}
+
+func fixtureFolderRoute(id string) string {
+ return "/api/efast/v1/folders/" + id + "/sub_objects"
+}
+
func infoMode(info os.FileInfo) os.FileMode {
if info == nil {
return 0
@@ -1527,6 +1909,18 @@ func (f *fixture) callCount(route string) int {
return f.calls[route]
}
+func (f *fixture) callCountPrefix(prefix string) int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ total := 0
+ for route, count := range f.calls {
+ if strings.HasPrefix(route, prefix) {
+ total += count
+ }
+ }
+ return total
+}
+
func (f *fixture) remainingFault(name string) int {
f.mu.Lock()
defer f.mu.Unlock()
@@ -1670,6 +2064,8 @@ func containsSecretMaterial(data []byte, objectURL string) bool {
lower := strings.ToLower(string(data))
markers := []string{
fixtureAccess, fixtureRefreshedAccess, fixtureRefresh, fixtureSecret, objectURL,
+ fixtureShareToken, fixturePublicLinkID, fixtureRealnameLinkID, fixturePasswordLinkID,
+ fixtureMobileLinkID, fixtureRedirectLinkID,
"authorization", "cookie", "signature=", "aws fixture", "fixture-single-generation-",
"fixture-part-", "fixture-complete-",
}
@@ -1705,7 +2101,11 @@ func runJSON(t *testing.T, r testRuntime, args []string, wantExit int) map[strin
t.Fatalf("%v stdout is not exactly one JSON object: %q", args, stdout.String())
}
combined := stdout.String() + stderr.String()
- for _, secret := range []string{fixtureAccess, fixtureRefresh, fixtureSecret, r.objectURL, "signature=", "AWS fixture"} {
+ for _, secret := range []string{
+ fixtureAccess, fixtureRefresh, fixtureSecret, fixtureShareToken, fixturePublicLinkID,
+ fixtureRealnameLinkID, fixturePasswordLinkID, fixtureMobileLinkID, fixtureRedirectLinkID,
+ r.objectURL, "signature=", "AWS fixture",
+ } {
if strings.Contains(combined, secret) {
t.Fatalf("%v leaked %q: stdout=%q stderr=%q", args, secret, stdout.String(), stderr.String())
}
@@ -1768,6 +2168,43 @@ type testRuntime struct {
objectURL string
}
+type fixtureShareSession struct{ session *sharelink.Session }
+
+func (s fixtureShareSession) Root() cli.ShareEntry {
+ return fixtureShareEntry(s.session.Root())
+}
+
+func (s fixtureShareSession) List(ctx context.Context, relativeDir string) ([]cli.ShareEntry, error) {
+ entries, err := s.session.List(ctx, relativeDir)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]cli.ShareEntry, len(entries))
+ for index, entry := range entries {
+ result[index] = fixtureShareEntry(entry)
+ }
+ return result, nil
+}
+
+func (s fixtureShareSession) Download(ctx context.Context, relativeFiles []string, localDir string, overwrite bool, progress upload.Progress) ([]cli.ShareGetResult, error) {
+ downloads, err := s.session.Download(ctx, relativeFiles, localDir, overwrite, progress)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]cli.ShareGetResult, len(downloads))
+ for index, item := range downloads {
+ result[index] = cli.ShareGetResult{
+ SharePath: item.Entry.Path, Revision: item.Result.Revision,
+ LocalPath: item.Result.LocalPath, Size: item.Result.Size,
+ }
+ }
+ return result, nil
+}
+
+func fixtureShareEntry(entry sharelink.Entry) cli.ShareEntry {
+ return cli.ShareEntry{Name: entry.Name, SharePath: entry.Path, Type: entry.Type, Size: entry.Size}
+}
+
func newRuntime(t *testing.T, f *fixture) testRuntime {
t.Helper()
root := t.TempDir()
@@ -1810,6 +2247,10 @@ func newRuntime(t *testing.T, f *fixture) testRuntime {
authorizer.CallbackTimeout = 5 * time.Second
manager := &auth.Manager{Store: store, OAuth: oauthClient, Authorizer: authorizer, Now: func() time.Time { return fixtureNow }}
api := anyshare.NewClient(f.control.URL, httpClient, manager)
+ shareOpener, err := sharelink.NewOpener(f.control.URL, httpClient, api)
+ if err != nil {
+ t.Fatalf("construct shared-link opener: %v", err)
+ }
remoteService := remote.NewService(api)
uploader := upload.NewUploader(f.control.URL, api, remoteService, upload.NewStateStore(paths.UploadStateDir), 4)
uploader.Now = func() time.Time { return fixtureNow }
@@ -1870,6 +2311,13 @@ func newRuntime(t *testing.T, f *fixture) testRuntime {
}
return cli.GetResult{RemotePath: item.Path, RemoteID: result.RemoteID, Revision: result.Revision, LocalPath: result.LocalPath, Size: result.Size}, nil
},
+ OpenShare: func(ctx context.Context, rawLink string) (cli.ShareSession, error) {
+ session, err := shareOpener.Open(ctx, rawLink)
+ if err != nil {
+ return nil, err
+ }
+ return fixtureShareSession{session: session}, nil
+ },
Delete: func(ctx context.Context, remotePath string, recursive bool) (cli.DeleteResult, error) {
result, err := remoteService.Delete(ctx, remotePath, recursive)
if err != nil {
diff --git a/internal/output/render.go b/internal/output/render.go
index 999bef4..f678fe1 100644
--- a/internal/output/render.go
+++ b/internal/output/render.go
@@ -8,6 +8,7 @@ import (
"net/url"
"regexp"
"strings"
+ "unicode"
)
type Renderer struct {
@@ -16,6 +17,25 @@ type Renderer struct {
JSON bool
}
+// TerminalText preserves ordinary Unicode while rendering terminal control
+// characters as visible escape sequences. Callers use it for remote or local
+// names inserted into otherwise structured human output.
+func TerminalText(value string) string {
+ var clean strings.Builder
+ for _, character := range value {
+ if !unicode.IsControl(character) {
+ clean.WriteRune(character)
+ continue
+ }
+ if character <= 0xff {
+ _, _ = fmt.Fprintf(&clean, `\x%02X`, character)
+ } else {
+ _, _ = fmt.Fprintf(&clean, `\u%04X`, character)
+ }
+ }
+ return clean.String()
+}
+
func NewRenderer(stdout, stderr io.Writer, jsonMode bool) *Renderer {
return &Renderer{Stdout: stdout, Stderr: stderr, JSON: jsonMode}
}
@@ -91,7 +111,7 @@ func redactGeneric(value any) any {
func sensitiveKey(key string) bool {
compact := strings.ToLower(key)
compact = strings.NewReplacer("_", "", "-", "", " ", "").Replace(compact)
- if compact == "code" || compact == "state" || compact == "codeverifier" || compact == "errordescription" {
+ if compact == "code" || compact == "state" || compact == "codeverifier" || compact == "errordescription" || compact == "linkid" {
return true
}
if compact == "body" || compact == "error" || compact == "err" || compact == "cause" || strings.HasPrefix(compact, "rawerror") || strings.Contains(compact, "responsebody") || strings.Contains(compact, "responsepayload") {
@@ -113,6 +133,9 @@ func redactURLString(value string) string {
if parsed.User != nil {
return "[REDACTED URL]"
}
+ if containsShareCapability(parsed.Path) || containsShareCapability(parsed.Fragment) {
+ return "[REDACTED URL]"
+ }
for key := range parsed.Query() {
if sensitiveKey(key) {
return "[REDACTED URL]"
@@ -121,6 +144,17 @@ func redactURLString(value string) string {
return value
}
+func containsShareCapability(value string) bool {
+ value = strings.TrimPrefix(value, "#")
+ parts := strings.Split(strings.Trim(value, "/"), "/")
+ for index := 0; index+1 < len(parts); index++ {
+ if strings.EqualFold(parts[index], "link") && parts[index+1] != "" {
+ return true
+ }
+ }
+ return false
+}
+
var (
urlPattern = regexp.MustCompile(`https?://[^\s]+`)
absoluteURLPattern = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^\s]+`)
diff --git a/internal/output/render_test.go b/internal/output/render_test.go
index 905cd7c..fee3c80 100644
--- a/internal/output/render_test.go
+++ b/internal/output/render_test.go
@@ -94,6 +94,24 @@ func TestRendererHumanStreams(t *testing.T) {
}
}
+func TestTerminalTextEscapesTerminalControls(t *testing.T) {
+ input := "报告\t\x1b]8;;https://evil.invalid\a名称\n.tsv\u009b31m"
+ got := output.TerminalText(input)
+ for _, character := range got {
+ if character < 0x20 || character >= 0x7f && character <= 0x9f {
+ t.Fatalf("terminal control U+%04X remained in %q", character, got)
+ }
+ }
+ for _, want := range []string{`\x09`, `\x1B`, `\x07`, `\x0A`, `\x9B`} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("TerminalText(%q) = %q, missing %q", input, got, want)
+ }
+ }
+ if !strings.Contains(got, "报告") || !strings.Contains(got, "名称") {
+ t.Fatalf("ordinary Unicode was lost: %q", got)
+ }
+}
+
func TestRedactMessageRemovesOAuthCallbackParameters(t *testing.T) {
message := "callback failed at http://127.0.0.1:43123/callback?code=fake-oauth-code&state=fake-oauth-state&code_verifier=fake-oauth-verifier&error_description=fake-oauth-description"
got := output.RedactMessage(message)
@@ -104,6 +122,19 @@ func TestRedactMessageRemovesOAuthCallbackParameters(t *testing.T) {
}
}
+func TestRedactMessageRemovesShareCapabilities(t *testing.T) {
+ for _, message := range []string{
+ "share failed at https://disk.pku.edu.cn/link/AA-ShareCapabilityMarker",
+ "legacy share https://disk.pku.edu.cn/anyshare/#/link/AR_ShareCapabilityMarker",
+ "metadata https://disk.pku.edu.cn/api/shared-link/v1/links/value?link_id=ShareCapabilityMarker",
+ } {
+ got := output.RedactMessage(message)
+ if strings.Contains(got, "ShareCapabilityMarker") || strings.Contains(got, "/link/AA-") || strings.Contains(got, "/link/AR_") {
+ t.Fatalf("share capability leaked in %q", got)
+ }
+ }
+}
+
func TestRendererFailureRedactsCompleteCredentialHeaders(t *testing.T) {
tests := []struct {
name string
diff --git a/internal/picker/model.go b/internal/picker/model.go
new file mode 100644
index 0000000..c5e61fc
--- /dev/null
+++ b/internal/picker/model.go
@@ -0,0 +1,195 @@
+package picker
+
+import (
+ "sort"
+ "strings"
+)
+
+type key uint8
+
+const (
+ keyUnknown key = iota
+ keyUp
+ keyDown
+ keyRight
+ keyLeft
+ keySpace
+ keyEnter
+ keyEscape
+ keyQuit
+ keyInterrupt
+)
+
+type effectKind uint8
+
+const (
+ effectNone effectKind = iota
+ effectEnterDirectory
+ effectLeaveDirectory
+ effectConfirm
+ effectCancel
+ effectInterrupt
+)
+
+type effect struct {
+ kind effectKind
+ entry Entry
+}
+
+type entryKey struct {
+ path string
+ id string
+}
+
+type location struct {
+ dir string
+ entries []Entry
+ cursor int
+ offset int
+}
+
+type model struct {
+ location
+ parents []location
+ selected map[entryKey]Entry
+}
+
+func newModel(entries []Entry) *model {
+ return &model{
+ location: location{entries: sortedEntries(entries)},
+ selected: make(map[entryKey]Entry),
+ }
+}
+
+func sortedEntries(entries []Entry) []Entry {
+ result := append([]Entry(nil), entries...)
+ sort.SliceStable(result, func(i, j int) bool {
+ iDir := result[i].Type == EntryDirectory
+ jDir := result[j].Type == EntryDirectory
+ if iDir != jDir {
+ return iDir
+ }
+ iName := strings.ToLower(sanitizeText(result[i].Name))
+ jName := strings.ToLower(sanitizeText(result[j].Name))
+ if iName != jName {
+ return iName < jName
+ }
+ if result[i].Name != result[j].Name {
+ return result[i].Name < result[j].Name
+ }
+ if result[i].Path != result[j].Path {
+ return result[i].Path < result[j].Path
+ }
+ return result[i].ID < result[j].ID
+ })
+ return result
+}
+
+func (m *model) handle(pressed key, rows int) effect {
+ switch pressed {
+ case keyUp:
+ if m.cursor > 0 {
+ m.cursor--
+ }
+ case keyDown:
+ if m.cursor+1 < len(m.entries) {
+ m.cursor++
+ }
+ case keyRight:
+ if entry, ok := m.current(); ok && entry.Type == EntryDirectory {
+ return effect{kind: effectEnterDirectory, entry: entry}
+ }
+ case keyLeft:
+ if len(m.parents) > 0 {
+ return effect{kind: effectLeaveDirectory}
+ }
+ case keySpace:
+ if entry, ok := m.current(); ok && entry.Type == EntryFile {
+ entryKey := keyFor(entry)
+ if _, exists := m.selected[entryKey]; exists {
+ delete(m.selected, entryKey)
+ } else {
+ m.selected[entryKey] = entry
+ }
+ }
+ case keyEnter:
+ return effect{kind: effectConfirm}
+ case keyEscape, keyQuit:
+ return effect{kind: effectCancel}
+ case keyInterrupt:
+ return effect{kind: effectInterrupt}
+ }
+ m.ensureVisible(rows)
+ return effect{kind: effectNone}
+}
+
+func (m *model) current() (Entry, bool) {
+ if m.cursor < 0 || m.cursor >= len(m.entries) {
+ return Entry{}, false
+ }
+ return m.entries[m.cursor], true
+}
+
+func (m *model) enter(dir string, entries []Entry) {
+ m.parents = append(m.parents, m.location)
+ m.location = location{dir: dir, entries: sortedEntries(entries)}
+}
+
+func (m *model) leave() {
+ if len(m.parents) == 0 {
+ return
+ }
+ last := len(m.parents) - 1
+ m.location = m.parents[last]
+ m.parents = m.parents[:last]
+}
+
+func (m *model) ensureVisible(rows int) {
+ if rows < 1 {
+ rows = 1
+ }
+ if len(m.entries) == 0 {
+ m.cursor = 0
+ m.offset = 0
+ return
+ }
+ if m.cursor < 0 {
+ m.cursor = 0
+ }
+ if m.cursor >= len(m.entries) {
+ m.cursor = len(m.entries) - 1
+ }
+ if m.cursor < m.offset {
+ m.offset = m.cursor
+ }
+ if m.cursor >= m.offset+rows {
+ m.offset = m.cursor - rows + 1
+ }
+ maxOffset := len(m.entries) - rows
+ if maxOffset < 0 {
+ maxOffset = 0
+ }
+ if m.offset > maxOffset {
+ m.offset = maxOffset
+ }
+ if m.offset < 0 {
+ m.offset = 0
+ }
+}
+
+func (m *model) selection() []Entry {
+ result := make([]Entry, 0, len(m.selected))
+ for _, entry := range m.selected {
+ result = append(result, entry)
+ }
+ return result
+}
+
+func (m *model) isSelected(entry Entry) bool {
+ _, ok := m.selected[keyFor(entry)]
+ return ok
+}
+
+func keyFor(entry Entry) entryKey {
+ return entryKey{path: entry.Path, id: entry.ID}
+}
diff --git a/internal/picker/model_test.go b/internal/picker/model_test.go
new file mode 100644
index 0000000..8027ce3
--- /dev/null
+++ b/internal/picker/model_test.go
@@ -0,0 +1,75 @@
+package picker
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestModelSortsDirectoriesBeforeFilesAndScrolls(t *testing.T) {
+ entries := []Entry{
+ {ID: "f-z", Path: "z.txt", Name: "z.txt", Type: EntryFile},
+ {ID: "d-b", Path: "Beta", Name: "Beta", Type: EntryDirectory},
+ {ID: "f-a", Path: "a.txt", Name: "a.txt", Type: EntryFile},
+ {ID: "d-a", Path: "alpha", Name: "alpha", Type: EntryDirectory},
+ }
+ model := newModel(entries)
+
+ got := make([]string, 0, len(model.entries))
+ for _, entry := range model.entries {
+ got = append(got, entry.ID)
+ }
+ want := []string{"d-a", "d-b", "f-a", "f-z"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("sorted IDs = %v, want %v", got, want)
+ }
+
+ for range 3 {
+ model.handle(keyDown, 2)
+ }
+ if model.cursor != 3 || model.offset != 2 {
+ t.Fatalf("cursor/offset = %d/%d, want 3/2", model.cursor, model.offset)
+ }
+ model.handle(keyUp, 2)
+ model.handle(keyUp, 2)
+ if model.cursor != 1 || model.offset != 1 {
+ t.Fatalf("cursor/offset after moving up = %d/%d, want 1/1", model.cursor, model.offset)
+ }
+}
+
+func TestSpaceOnlySelectsFiles(t *testing.T) {
+ model := newModel([]Entry{
+ {ID: "dir", Path: "folder", Name: "folder", Type: EntryDirectory},
+ {ID: "file", Path: "file.txt", Name: "file.txt", Type: EntryFile},
+ })
+
+ model.handle(keySpace, 10)
+ if len(model.selected) != 0 {
+ t.Fatalf("selected directory; selected = %#v", model.selected)
+ }
+ model.handle(keyDown, 10)
+ model.handle(keySpace, 10)
+ if len(model.selected) != 1 {
+ t.Fatalf("selected count = %d, want 1", len(model.selected))
+ }
+ model.handle(keySpace, 10)
+ if len(model.selected) != 0 {
+ t.Fatalf("second toggle did not clear selection: %#v", model.selected)
+ }
+}
+
+func TestNavigationRestoresParentPosition(t *testing.T) {
+ model := newModel([]Entry{
+ {ID: "one", Path: "one", Name: "one", Type: EntryDirectory},
+ {ID: "two", Path: "two", Name: "two", Type: EntryDirectory},
+ })
+ model.handle(keyDown, 1)
+ effect := model.handle(keyRight, 1)
+ if effect.kind != effectEnterDirectory || effect.entry.ID != "two" {
+ t.Fatalf("enter effect = %#v", effect)
+ }
+ model.enter(effect.entry.Path, nil)
+ model.leave()
+ if model.dir != "" || model.cursor != 1 || model.offset != 1 {
+ t.Fatalf("restored location = dir %q cursor %d offset %d", model.dir, model.cursor, model.offset)
+ }
+}
diff --git a/internal/picker/picker.go b/internal/picker/picker.go
new file mode 100644
index 0000000..2bf74ab
--- /dev/null
+++ b/internal/picker/picker.go
@@ -0,0 +1,152 @@
+// Package picker implements the interactive file selector used for shared links.
+package picker
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/signal"
+ "sort"
+)
+
+// EntryType identifies the kind of an entry returned by a Source.
+type EntryType uint8
+
+const (
+ EntryFile EntryType = iota
+ EntryDirectory
+)
+
+// Entry is one file or directory in a shared link.
+//
+// ID is opaque to the picker and is returned unchanged. Path is relative to the
+// share root and is used when asking Source for a directory's children. Size may
+// be -1 when it is unknown.
+type Entry struct {
+ ID string
+ Path string
+ Name string
+ Type EntryType
+ Size int64
+}
+
+// Source lists the immediate children of relativeDir. Implementations should
+// not recursively expand descendants.
+type Source interface {
+ List(ctx context.Context, relativeDir string) ([]Entry, error)
+}
+
+// Options configures the interactive picker. Input and Output must both refer
+// to terminals. Title is optional and is sanitized before display.
+type Options struct {
+ Input *os.File
+ Output *os.File
+ Title string
+}
+
+var (
+ // ErrNotTerminal is returned when either side of the picker is not a TTY.
+ ErrNotTerminal = errors.New("picker requires terminal input and output")
+ // ErrCanceled is returned when the user presses q or Escape.
+ ErrCanceled = errors.New("file selection canceled")
+)
+
+// Run opens an interactive, lazy directory browser. The returned files are
+// sorted by Path (and then ID) so callers receive deterministic results.
+func Run(ctx context.Context, source Source, options Options) ([]Entry, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if source == nil {
+ return nil, errors.New("picker source is nil")
+ }
+ if err := validateTerminalFiles(options.Input, options.Output); err != nil {
+ return nil, err
+ }
+
+ runCtx, stopSignals := signal.NotifyContext(ctx, pickerSignals()...)
+ defer stopSignals()
+
+ entries, err := source.List(runCtx, "")
+ if err != nil {
+ return nil, fmt.Errorf("list shared-link root: %w", err)
+ }
+ if err := runCtx.Err(); err != nil {
+ return nil, err
+ }
+
+ terminal, err := newTerminalSession(options.Input, options.Output)
+ if err != nil {
+ return nil, err
+ }
+ return runSession(runCtx, source, terminal, options.Title, entries)
+}
+
+type session interface {
+ Size() (width, height int)
+ Draw(screen string) error
+ ReadKey(context.Context) (key, error)
+ Close() error
+}
+
+func runSession(ctx context.Context, source Source, terminal session, title string, root []Entry) (selected []Entry, err error) {
+ defer func() {
+ if closeErr := terminal.Close(); closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("restore terminal: %w", closeErr))
+ }
+ }()
+
+ model := newModel(root)
+ for {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ width, height := terminal.Size()
+ rows := visibleRows(height)
+ model.ensureVisible(rows)
+ if err := terminal.Draw(render(model, title, width, height)); err != nil {
+ return nil, fmt.Errorf("draw picker: %w", err)
+ }
+
+ pressed, err := terminal.ReadKey(ctx)
+ if err != nil {
+ if errors.Is(err, context.Canceled) && ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ if errors.Is(err, io.EOF) {
+ return nil, io.EOF
+ }
+ return nil, fmt.Errorf("read picker input: %w", err)
+ }
+
+ effect := model.handle(pressed, rows)
+ switch effect.kind {
+ case effectNone:
+ continue
+ case effectEnterDirectory:
+ children, listErr := source.List(ctx, effect.entry.Path)
+ if listErr != nil {
+ return nil, fmt.Errorf("list shared-link directory: %w", listErr)
+ }
+ model.enter(effect.entry.Path, children)
+ case effectLeaveDirectory:
+ model.leave()
+ case effectConfirm:
+ selected = model.selection()
+ sort.SliceStable(selected, func(i, j int) bool {
+ if selected[i].Path != selected[j].Path {
+ return selected[i].Path < selected[j].Path
+ }
+ return selected[i].ID < selected[j].ID
+ })
+ return selected, nil
+ case effectCancel:
+ return nil, ErrCanceled
+ case effectInterrupt:
+ return nil, context.Canceled
+ }
+ }
+}
diff --git a/internal/picker/picker_test.go b/internal/picker/picker_test.go
new file mode 100644
index 0000000..f2f45e2
--- /dev/null
+++ b/internal/picker/picker_test.go
@@ -0,0 +1,233 @@
+package picker
+
+import (
+ "context"
+ "errors"
+ "io"
+ "reflect"
+ "sync"
+ "testing"
+)
+
+type fakeSource struct {
+ mu sync.Mutex
+ entries map[string][]Entry
+ errors map[string]error
+ calls []string
+}
+
+func (s *fakeSource) List(ctx context.Context, relativeDir string) ([]Entry, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.calls = append(s.calls, relativeDir)
+ if err := s.errors[relativeDir]; err != nil {
+ return nil, err
+ }
+ return append([]Entry(nil), s.entries[relativeDir]...), nil
+}
+
+func (s *fakeSource) listed() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]string(nil), s.calls...)
+}
+
+type fakeSession struct {
+ mu sync.Mutex
+ keys []key
+ read func(context.Context) (key, error)
+ readErr error
+ drawErr error
+ closeErr error
+ screens []string
+ closed bool
+}
+
+func (s *fakeSession) Size() (int, int) { return 80, 12 }
+
+func (s *fakeSession) Draw(screen string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.screens = append(s.screens, screen)
+ return s.drawErr
+}
+
+func (s *fakeSession) ReadKey(ctx context.Context) (key, error) {
+ if s.read != nil {
+ return s.read(ctx)
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if len(s.keys) > 0 {
+ pressed := s.keys[0]
+ s.keys = s.keys[1:]
+ return pressed, nil
+ }
+ if s.readErr != nil {
+ return keyUnknown, s.readErr
+ }
+ return keyUnknown, io.EOF
+}
+
+func (s *fakeSession) Close() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.closed = true
+ return s.closeErr
+}
+
+func (s *fakeSession) wasClosed() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.closed
+}
+
+func TestRunSessionListsLazilyAndPreservesCrossDirectorySelection(t *testing.T) {
+ directory := Entry{ID: "dir", Path: "docs", Name: "docs", Type: EntryDirectory}
+ rootFile := Entry{ID: "a", Path: "a.txt", Name: "a.txt", Type: EntryFile, Size: 10}
+ nestedFile := Entry{ID: "z", Path: "docs/z.txt", Name: "z.txt", Type: EntryFile, Size: 20}
+ source := &fakeSource{entries: map[string][]Entry{
+ "docs": {nestedFile},
+ }}
+ terminal := &fakeSession{keys: []key{
+ keyRight, // enter docs; this is the first time its children are listed
+ keySpace, // select docs/z.txt
+ keyLeft, // restore root without listing it again
+ keyDown,
+ keySpace, // select a.txt
+ keyEnter,
+ }}
+
+ selected, err := runSession(context.Background(), source, terminal, "Pick", []Entry{rootFile, directory})
+ if err != nil {
+ t.Fatalf("runSession: %v", err)
+ }
+ want := []Entry{rootFile, nestedFile}
+ if !reflect.DeepEqual(selected, want) {
+ t.Fatalf("selected = %#v, want %#v", selected, want)
+ }
+ if calls := source.listed(); !reflect.DeepEqual(calls, []string{"docs"}) {
+ t.Fatalf("List calls = %v, want only entered directory", calls)
+ }
+ if !terminal.wasClosed() {
+ t.Fatal("terminal was not closed")
+ }
+}
+
+func TestRunSessionEmptyConfirmation(t *testing.T) {
+ terminal := &fakeSession{keys: []key{keyEnter}}
+ selected, err := runSession(context.Background(), &fakeSource{}, terminal, "", nil)
+ if err != nil {
+ t.Fatalf("runSession: %v", err)
+ }
+ if selected == nil || len(selected) != 0 {
+ t.Fatalf("selected = %#v, want non-nil empty slice", selected)
+ }
+ if !terminal.wasClosed() {
+ t.Fatal("terminal was not closed")
+ }
+}
+
+func TestRunSessionCancelAndInterrupt(t *testing.T) {
+ tests := []struct {
+ name string
+ key key
+ want error
+ }{
+ {name: "quit", key: keyQuit, want: ErrCanceled},
+ {name: "escape", key: keyEscape, want: ErrCanceled},
+ {name: "ctrl-c", key: keyInterrupt, want: context.Canceled},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ terminal := &fakeSession{keys: []key{test.key}}
+ _, err := runSession(context.Background(), &fakeSource{}, terminal, "", nil)
+ if !errors.Is(err, test.want) {
+ t.Fatalf("error = %v, want errors.Is(_, %v)", err, test.want)
+ }
+ if !terminal.wasClosed() {
+ t.Fatal("terminal was not closed")
+ }
+ })
+ }
+}
+
+func TestRunSessionRestoresOnEveryErrorPath(t *testing.T) {
+ sentinel := errors.New("sentinel")
+ tests := []struct {
+ name string
+ source *fakeSource
+ terminal *fakeSession
+ }{
+ {
+ name: "EOF",
+ source: &fakeSource{},
+ terminal: &fakeSession{readErr: io.EOF},
+ },
+ {
+ name: "read error",
+ source: &fakeSource{},
+ terminal: &fakeSession{readErr: sentinel},
+ },
+ {
+ name: "draw error",
+ source: &fakeSource{},
+ terminal: &fakeSession{drawErr: sentinel},
+ },
+ {
+ name: "list error",
+ source: &fakeSource{errors: map[string]error{
+ "dir": sentinel,
+ }},
+ terminal: &fakeSession{keys: []key{keyRight}},
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ root := []Entry(nil)
+ if test.name == "list error" {
+ root = []Entry{{ID: "dir", Path: "dir", Name: "dir", Type: EntryDirectory}}
+ }
+ _, _ = runSession(context.Background(), test.source, test.terminal, "", root)
+ if !test.terminal.wasClosed() {
+ t.Fatal("terminal was not closed")
+ }
+ })
+ }
+}
+
+func TestRunSessionContextCancellationClosesTerminal(t *testing.T) {
+ started := make(chan struct{})
+ terminal := &fakeSession{read: func(ctx context.Context) (key, error) {
+ close(started)
+ <-ctx.Done()
+ return keyUnknown, ctx.Err()
+ }}
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() {
+ _, err := runSession(ctx, &fakeSource{}, terminal, "", nil)
+ done <- err
+ }()
+ <-started
+ cancel()
+ if err := <-done; !errors.Is(err, context.Canceled) {
+ t.Fatalf("error = %v, want context.Canceled", err)
+ }
+ if !terminal.wasClosed() {
+ t.Fatal("terminal was not closed")
+ }
+}
+
+func TestRunSessionJoinsCloseError(t *testing.T) {
+ closeErr := errors.New("close failed")
+ terminal := &fakeSession{keys: []key{keyQuit}, closeErr: closeErr}
+ _, err := runSession(context.Background(), &fakeSource{}, terminal, "", nil)
+ if !errors.Is(err, ErrCanceled) || !errors.Is(err, closeErr) {
+ t.Fatalf("joined error = %v, want cancellation and close errors", err)
+ }
+}
diff --git a/internal/picker/render.go b/internal/picker/render.go
new file mode 100644
index 0000000..059ebfa
--- /dev/null
+++ b/internal/picker/render.go
@@ -0,0 +1,170 @@
+package picker
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+const defaultTitle = "Select files to download"
+
+func visibleRows(height int) int {
+ const nonListRows = 6
+ rows := height - nonListRows
+ if rows < 1 {
+ return 1
+ }
+ return rows
+}
+
+func render(m *model, title string, width, height int) string {
+ if width < 20 {
+ width = 20
+ }
+ if height < 7 {
+ height = 7
+ }
+ rows := visibleRows(height)
+ m.ensureVisible(rows)
+
+ if title == "" {
+ title = defaultTitle
+ }
+ dir := m.dir
+ if dir == "" {
+ dir = "/"
+ }
+
+ var output strings.Builder
+ output.WriteString("\x1b[H\x1b[2J")
+ output.WriteString(fitLine(sanitizeText(title), width))
+ output.WriteString("\r\n")
+ output.WriteString(fitLine("Location: "+sanitizeText(dir), width))
+ output.WriteString("\r\n")
+ output.WriteString(fitLine(fmt.Sprintf("Selected: %d", len(m.selected)), width))
+ output.WriteString("\r\n\r\n")
+
+ end := m.offset + rows
+ if end > len(m.entries) {
+ end = len(m.entries)
+ }
+ for index := m.offset; index < end; index++ {
+ entry := m.entries[index]
+ cursor := " "
+ if index == m.cursor {
+ cursor = "> "
+ }
+ var marker string
+ var suffix string
+ if entry.Type == EntryDirectory {
+ marker = "[D] "
+ suffix = "/"
+ } else if m.isSelected(entry) {
+ marker = "[x] "
+ } else {
+ marker = "[ ] "
+ }
+
+ name := sanitizeText(entry.Name) + suffix
+ line := cursor + marker + name
+ if entry.Type == EntryFile && entry.Size >= 0 {
+ size := formatSize(entry.Size)
+ available := width - displayWidth(line) - displayWidth(size) - 2
+ if available >= 1 {
+ line += strings.Repeat(" ", available) + " " + size
+ }
+ }
+ output.WriteString(fitLine(line, width))
+ output.WriteString("\r\n")
+ }
+ for index := end - m.offset; index < rows; index++ {
+ output.WriteString("\r\n")
+ }
+
+ output.WriteString(fitLine("Arrows/j/k: move l/right: open h/left/backspace: back", width))
+ output.WriteString("\r\n")
+ output.WriteString(fitLine("Space: select file Enter: confirm q/Esc: cancel", width))
+ return output.String()
+}
+
+func sanitizeText(value string) string {
+ return strings.Map(func(r rune) rune {
+ if r == utf8.RuneError || unicode.IsControl(r) {
+ return '\uFFFD'
+ }
+ return r
+ }, value)
+}
+
+func fitLine(value string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ if displayWidth(value) <= width {
+ return value
+ }
+ if width == 1 {
+ return "…"
+ }
+ return truncateWidth(value, width-1) + "…"
+}
+
+func truncateWidth(value string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ var result strings.Builder
+ used := 0
+ for _, r := range value {
+ runeWidth := displayRuneWidth(r)
+ if used+runeWidth > width {
+ break
+ }
+ result.WriteRune(r)
+ used += runeWidth
+ }
+ return result.String()
+}
+
+func displayWidth(value string) int {
+ width := 0
+ for _, r := range value {
+ width += displayRuneWidth(r)
+ }
+ return width
+}
+
+func displayRuneWidth(r rune) int {
+ if unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) {
+ return 0
+ }
+ if r >= 0x1100 && (r <= 0x115f ||
+ r == 0x2329 || r == 0x232a ||
+ (r >= 0x2e80 && r <= 0xa4cf && r != 0x303f) ||
+ (r >= 0xac00 && r <= 0xd7a3) ||
+ (r >= 0xf900 && r <= 0xfaff) ||
+ (r >= 0xfe10 && r <= 0xfe19) ||
+ (r >= 0xfe30 && r <= 0xfe6f) ||
+ (r >= 0xff00 && r <= 0xff60) ||
+ (r >= 0xffe0 && r <= 0xffe6) ||
+ (r >= 0x1f300 && r <= 0x1faff) ||
+ (r >= 0x20000 && r <= 0x3fffd)) {
+ return 2
+ }
+ return 1
+}
+
+func formatSize(size int64) string {
+ const unit = int64(1024)
+ if size < unit {
+ return fmt.Sprintf("%d B", size)
+ }
+ divisor := unit
+ exponent := 0
+ for quotient := size / unit; quotient >= unit && exponent < 5; quotient /= unit {
+ divisor *= unit
+ exponent++
+ }
+ return fmt.Sprintf("%.1f %ciB", float64(size)/float64(divisor), "KMGTPE"[exponent])
+}
diff --git a/internal/picker/render_test.go b/internal/picker/render_test.go
new file mode 100644
index 0000000..7ef2b0f
--- /dev/null
+++ b/internal/picker/render_test.go
@@ -0,0 +1,65 @@
+package picker
+
+import (
+ "strings"
+ "testing"
+ "unicode"
+)
+
+func TestSanitizeTextRemovesTerminalControls(t *testing.T) {
+ input := "name\x1b[31m\r\n\x00\x7f\u0085end"
+ got := sanitizeText(input)
+ for _, r := range got {
+ if unicode.IsControl(r) {
+ t.Fatalf("sanitized text contains control rune U+%04X: %q", r, got)
+ }
+ }
+ if strings.ContainsRune(got, '\x1b') || strings.ContainsAny(got, "\r\n") {
+ t.Fatalf("sanitized text contains terminal control: %q", got)
+ }
+ if !strings.Contains(got, "[31m") {
+ t.Fatalf("expected harmless remainder of ANSI sequence to remain visible: %q", got)
+ }
+}
+
+func TestRenderSanitizesSourceAndTitleText(t *testing.T) {
+ model := newModel([]Entry{{
+ ID: "id",
+ Path: "path",
+ Name: "unsafe\x1b[2J\nname",
+ Type: EntryFile,
+ }})
+ model.dir = "dir\r\n\x1b[31m"
+ screen := render(model, "title\x1b]0;owned\a", 80, 12)
+
+ if strings.Contains(screen, "unsafe\x1b[2J") || strings.Contains(screen, "dir\r\n") || strings.Contains(screen, "title\x1b]0") {
+ t.Fatalf("rendered screen contains unsanitized source text: %q", screen)
+ }
+ if !strings.HasPrefix(screen, "\x1b[H\x1b[2J") {
+ t.Fatalf("rendered screen lacks picker clear sequence: %q", screen)
+ }
+}
+
+func TestFitLineAccountsForWideRunes(t *testing.T) {
+ got := fitLine("ab北大cd", 6)
+ if got != "ab北…" {
+ t.Fatalf("fitLine = %q, want %q", got, "ab北…")
+ }
+ if width := displayWidth(got); width != 5 {
+ t.Fatalf("display width = %d, want 5", width)
+ }
+}
+
+func TestFormatSize(t *testing.T) {
+ tests := map[int64]string{
+ 0: "0 B",
+ 1023: "1023 B",
+ 1024: "1.0 KiB",
+ 1024 * 1024: "1.0 MiB",
+ }
+ for size, want := range tests {
+ if got := formatSize(size); got != want {
+ t.Errorf("formatSize(%d) = %q, want %q", size, got, want)
+ }
+ }
+}
diff --git a/internal/picker/signals_other.go b/internal/picker/signals_other.go
new file mode 100644
index 0000000..945839b
--- /dev/null
+++ b/internal/picker/signals_other.go
@@ -0,0 +1,9 @@
+//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris
+
+package picker
+
+import "os"
+
+func pickerSignals() []os.Signal {
+ return []os.Signal{os.Interrupt}
+}
diff --git a/internal/picker/signals_unix.go b/internal/picker/signals_unix.go
new file mode 100644
index 0000000..d7c41c3
--- /dev/null
+++ b/internal/picker/signals_unix.go
@@ -0,0 +1,18 @@
+//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
+
+package picker
+
+import (
+ "os"
+ "syscall"
+)
+
+func pickerSignals() []os.Signal {
+ return []os.Signal{
+ os.Interrupt,
+ syscall.SIGHUP,
+ syscall.SIGTERM,
+ syscall.SIGQUIT,
+ syscall.SIGTSTP,
+ }
+}
diff --git a/internal/picker/terminal_linux.go b/internal/picker/terminal_linux.go
new file mode 100644
index 0000000..c8593b5
--- /dev/null
+++ b/internal/picker/terminal_linux.go
@@ -0,0 +1,254 @@
+//go:build linux
+
+package picker
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+const (
+ defaultTerminalWidth = 80
+ defaultTerminalHeight = 24
+ inputPollInterval = 100 * time.Millisecond
+ escapeSequenceWait = 20 * time.Millisecond
+)
+
+type terminalSession struct {
+ input *os.File
+ output *os.File
+ original syscall.Termios
+ pending []byte
+ closed bool
+ screenReady bool
+}
+
+type windowSize struct {
+ rows uint16
+ columns uint16
+ xpixels uint16
+ ypixels uint16
+}
+
+func newTerminalSession(input, output *os.File) (*terminalSession, error) {
+ if err := validateTerminalFiles(input, output); err != nil {
+ return nil, err
+ }
+ original, err := readTermios(input.Fd())
+ if err != nil {
+ return nil, fmt.Errorf("read terminal mode: %w", err)
+ }
+
+ raw := original
+ raw.Iflag &^= syscall.IGNBRK | syscall.BRKINT | syscall.PARMRK | syscall.ISTRIP |
+ syscall.INLCR | syscall.IGNCR | syscall.ICRNL | syscall.IXON
+ raw.Oflag &^= syscall.OPOST
+ raw.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG | syscall.IEXTEN
+ raw.Cflag &^= syscall.CSIZE | syscall.PARENB
+ raw.Cflag |= syscall.CS8
+ raw.Cc[syscall.VMIN] = 1
+ raw.Cc[syscall.VTIME] = 0
+ if err := writeTermios(input.Fd(), &raw); err != nil {
+ return nil, fmt.Errorf("enable terminal raw mode: %w", err)
+ }
+
+ session := &terminalSession{input: input, output: output, original: original, screenReady: true}
+ if _, err := io.WriteString(output, "\x1b[?1049h\x1b[?25l"); err != nil {
+ return nil, errors.Join(fmt.Errorf("initialize picker screen: %w", err), session.Close())
+ }
+ return session, nil
+}
+
+func validateTerminalFiles(input, output *os.File) error {
+ if input == nil || output == nil {
+ return ErrNotTerminal
+ }
+ if _, err := readTermios(input.Fd()); err != nil {
+ return fmt.Errorf("%w: input: %v", ErrNotTerminal, err)
+ }
+ if _, err := readTermios(output.Fd()); err != nil {
+ return fmt.Errorf("%w: output: %v", ErrNotTerminal, err)
+ }
+ return nil
+}
+
+func (s *terminalSession) Size() (int, int) {
+ var size windowSize
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, s.output.Fd(), syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&size)))
+ if errno != 0 || size.columns == 0 || size.rows == 0 {
+ return defaultTerminalWidth, defaultTerminalHeight
+ }
+ return int(size.columns), int(size.rows)
+}
+
+func (s *terminalSession) Draw(screen string) error {
+ _, err := io.WriteString(s.output, screen)
+ return err
+}
+
+func (s *terminalSession) ReadKey(ctx context.Context) (key, error) {
+ for {
+ if parsed, consumed, needMore := parseKey(s.pending); !needMore {
+ s.pending = s.pending[consumed:]
+ return parsed, nil
+ }
+
+ wait := inputPollInterval
+ if len(s.pending) > 0 && s.pending[0] == 0x1b {
+ wait = escapeSequenceWait
+ }
+ ready, err := waitReadable(ctx, int(s.input.Fd()), wait)
+ if err != nil {
+ return keyUnknown, err
+ }
+ if !ready {
+ if len(s.pending) > 0 && s.pending[0] == 0x1b {
+ s.pending = s.pending[1:]
+ return keyEscape, nil
+ }
+ continue
+ }
+
+ var buffer [64]byte
+ count, err := syscall.Read(int(s.input.Fd()), buffer[:])
+ if err != nil {
+ if err == syscall.EINTR || err == syscall.EAGAIN {
+ continue
+ }
+ return keyUnknown, err
+ }
+ if count == 0 {
+ return keyUnknown, io.EOF
+ }
+ s.pending = append(s.pending, buffer[:count]...)
+ }
+}
+
+func (s *terminalSession) Close() error {
+ if s.closed {
+ return nil
+ }
+ s.closed = true
+ var screenErr error
+ if s.screenReady {
+ _, screenErr = io.WriteString(s.output, "\x1b[0m\x1b[?25h\x1b[?1049l")
+ }
+ return errors.Join(screenErr, s.restoreTermios())
+}
+
+func (s *terminalSession) restoreTermios() error {
+ if err := writeTermios(s.input.Fd(), &s.original); err != nil {
+ return fmt.Errorf("restore terminal mode: %w", err)
+ }
+ return nil
+}
+
+func parseKey(input []byte) (pressed key, consumed int, needMore bool) {
+ if len(input) == 0 {
+ return keyUnknown, 0, true
+ }
+ switch input[0] {
+ case 0x03:
+ return keyInterrupt, 1, false
+ case 0x08, 0x7f:
+ return keyLeft, 1, false
+ case '\r', '\n':
+ return keyEnter, 1, false
+ case ' ':
+ return keySpace, 1, false
+ case 'j':
+ return keyDown, 1, false
+ case 'k':
+ return keyUp, 1, false
+ case 'l':
+ return keyRight, 1, false
+ case 'h':
+ return keyLeft, 1, false
+ case 'q':
+ return keyQuit, 1, false
+ case 0x1b:
+ if len(input) == 1 {
+ return keyUnknown, 0, true
+ }
+ if input[1] != '[' && input[1] != 'O' {
+ return keyEscape, 1, false
+ }
+ if len(input) < 3 {
+ return keyUnknown, 0, true
+ }
+ switch input[2] {
+ case 'A':
+ return keyUp, 3, false
+ case 'B':
+ return keyDown, 3, false
+ case 'C':
+ return keyRight, 3, false
+ case 'D':
+ return keyLeft, 3, false
+ default:
+ return keyEscape, 1, false
+ }
+ default:
+ return keyUnknown, 1, false
+ }
+}
+
+func waitReadable(ctx context.Context, fd int, timeout time.Duration) (bool, error) {
+ if err := ctx.Err(); err != nil {
+ return false, err
+ }
+ var set syscall.FdSet
+ wordBits := int(unsafe.Sizeof(set.Bits[0]) * 8)
+ if fd < 0 || fd >= len(set.Bits)*wordBits {
+ return false, fmt.Errorf("terminal file descriptor %d exceeds select capacity", fd)
+ }
+ fdSet(fd, &set)
+ timeval := syscall.NsecToTimeval(timeout.Nanoseconds())
+ count, err := syscall.Select(fd+1, &set, nil, nil, &timeval)
+ if err != nil {
+ if err == syscall.EINTR {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return false, ctxErr
+ }
+ return false, nil
+ }
+ return false, err
+ }
+ if err := ctx.Err(); err != nil {
+ return false, err
+ }
+ return count > 0 && fdIsSet(fd, &set), nil
+}
+
+func fdSet(fd int, set *syscall.FdSet) {
+ wordBits := int(unsafe.Sizeof(set.Bits[0]) * 8)
+ set.Bits[fd/wordBits] |= 1 << (uint(fd) % uint(wordBits))
+}
+
+func fdIsSet(fd int, set *syscall.FdSet) bool {
+ wordBits := int(unsafe.Sizeof(set.Bits[0]) * 8)
+ return set.Bits[fd/wordBits]&(1<<(uint(fd)%uint(wordBits))) != 0
+}
+
+func readTermios(fd uintptr) (syscall.Termios, error) {
+ var value syscall.Termios
+ _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd, syscall.TCGETS, uintptr(unsafe.Pointer(&value)), 0, 0, 0)
+ if errno != 0 {
+ return syscall.Termios{}, errno
+ }
+ return value, nil
+}
+
+func writeTermios(fd uintptr, value *syscall.Termios) error {
+ _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd, syscall.TCSETS, uintptr(unsafe.Pointer(value)), 0, 0, 0)
+ if errno != 0 {
+ return errno
+ }
+ return nil
+}
diff --git a/internal/picker/terminal_linux_test.go b/internal/picker/terminal_linux_test.go
new file mode 100644
index 0000000..ea3b112
--- /dev/null
+++ b/internal/picker/terminal_linux_test.go
@@ -0,0 +1,220 @@
+//go:build linux
+
+package picker
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "syscall"
+ "testing"
+ "time"
+ "unsafe"
+)
+
+func TestParseKey(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want key
+ consumed int
+ more bool
+ }{
+ {name: "empty", more: true},
+ {name: "up", input: "\x1b[A", want: keyUp, consumed: 3},
+ {name: "down application mode", input: "\x1bOB", want: keyDown, consumed: 3},
+ {name: "right", input: "\x1b[C", want: keyRight, consumed: 3},
+ {name: "left", input: "\x1b[D", want: keyLeft, consumed: 3},
+ {name: "partial escape", input: "\x1b[", more: true},
+ {name: "escape before text", input: "\x1bx", want: keyEscape, consumed: 1},
+ {name: "space", input: " ", want: keySpace, consumed: 1},
+ {name: "enter", input: "\r", want: keyEnter, consumed: 1},
+ {name: "backspace", input: "\x7f", want: keyLeft, consumed: 1},
+ {name: "ctrl-c", input: "\x03", want: keyInterrupt, consumed: 1},
+ {name: "unknown", input: "x", want: keyUnknown, consumed: 1},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got, consumed, more := parseKey([]byte(test.input))
+ if got != test.want || consumed != test.consumed || more != test.more {
+ t.Fatalf("parseKey(%q) = (%v, %d, %v), want (%v, %d, %v)", test.input, got, consumed, more, test.want, test.consumed, test.more)
+ }
+ })
+ }
+}
+
+func TestRunRestoresRealTermiosOnCancellation(t *testing.T) {
+ master, slave := openPTY(t)
+ defer master.Close()
+ defer slave.Close()
+
+ before, err := readTermios(slave.Fd())
+ if err != nil {
+ t.Fatalf("read original termios: %v", err)
+ }
+ source := &fakeSource{entries: map[string][]Entry{"": nil}}
+ done := make(chan error, 1)
+ go func() {
+ _, err := Run(context.Background(), source, Options{Input: slave, Output: slave, Title: "test"})
+ done <- err
+ }()
+
+ waitForRawMode(t, slave, before)
+ if _, err := master.Write([]byte{'q'}); err != nil {
+ t.Fatalf("write cancel key: %v", err)
+ }
+ select {
+ case err := <-done:
+ if !errors.Is(err, ErrCanceled) {
+ t.Fatalf("Run error = %v, want ErrCanceled", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("Run did not return")
+ }
+
+ after, err := readTermios(slave.Fd())
+ if err != nil {
+ t.Fatalf("read restored termios: %v", err)
+ }
+ if after != before {
+ t.Fatalf("termios was not restored\nbefore: %#v\nafter: %#v", before, after)
+ }
+}
+
+func TestRunRestoresRealTermiosOnContextCancellation(t *testing.T) {
+ master, slave := openPTY(t)
+ defer master.Close()
+ defer slave.Close()
+
+ before, err := readTermios(slave.Fd())
+ if err != nil {
+ t.Fatalf("read original termios: %v", err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ source := &fakeSource{entries: map[string][]Entry{"": nil}}
+ done := make(chan error, 1)
+ go func() {
+ _, err := Run(ctx, source, Options{Input: slave, Output: slave})
+ done <- err
+ }()
+
+ waitForRawMode(t, slave, before)
+ cancel()
+ select {
+ case err := <-done:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("Run error = %v, want context.Canceled", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("Run did not return")
+ }
+
+ after, err := readTermios(slave.Fd())
+ if err != nil {
+ t.Fatalf("read restored termios: %v", err)
+ }
+ if after != before {
+ t.Fatalf("termios was not restored after context cancellation")
+ }
+}
+
+func TestRunRestoresRealTermiosOnSignal(t *testing.T) {
+ master, slave := openPTY(t)
+ defer master.Close()
+ defer slave.Close()
+
+ before, err := readTermios(slave.Fd())
+ if err != nil {
+ t.Fatalf("read original termios: %v", err)
+ }
+ done := make(chan error, 1)
+ go func() {
+ _, err := Run(context.Background(), &fakeSource{}, Options{Input: slave, Output: slave})
+ done <- err
+ }()
+
+ waitForRawMode(t, slave, before)
+ if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil {
+ t.Fatalf("send SIGTERM: %v", err)
+ }
+ select {
+ case err := <-done:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("Run error = %v, want context.Canceled", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("Run did not return after SIGTERM")
+ }
+
+ after, err := readTermios(slave.Fd())
+ if err != nil {
+ t.Fatalf("read restored termios: %v", err)
+ }
+ if after != before {
+ t.Fatalf("termios was not restored after signal")
+ }
+}
+
+func TestRunRejectsNonTTY(t *testing.T) {
+ input, err := os.Open(os.DevNull)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer input.Close()
+ output, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer output.Close()
+
+ source := &fakeSource{}
+ _, err = Run(context.Background(), source, Options{Input: input, Output: output})
+ if !errors.Is(err, ErrNotTerminal) {
+ t.Fatalf("Run error = %v, want ErrNotTerminal", err)
+ }
+ if calls := source.listed(); len(calls) != 0 {
+ t.Fatalf("source called before TTY validation: %v", calls)
+ }
+}
+
+func waitForRawMode(t *testing.T, slave *os.File, original syscall.Termios) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ current, err := readTermios(slave.Fd())
+ if err != nil {
+ t.Fatalf("read termios while waiting for raw mode: %v", err)
+ }
+ if current != original {
+ return
+ }
+ time.Sleep(time.Millisecond)
+ }
+ t.Fatal("terminal did not enter raw mode")
+}
+
+func openPTY(t *testing.T) (*os.File, *os.File) {
+ t.Helper()
+ master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0)
+ if err != nil {
+ t.Skipf("open /dev/ptmx: %v", err)
+ }
+
+ locked := int32(0)
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, master.Fd(), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&locked))); errno != 0 {
+ master.Close()
+ t.Fatalf("unlock PTY: %v", errno)
+ }
+ var number uint32
+ if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, master.Fd(), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&number))); errno != 0 {
+ master.Close()
+ t.Fatalf("get PTY number: %v", errno)
+ }
+ slave, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", number), os.O_RDWR|syscall.O_NOCTTY, 0)
+ if err != nil {
+ master.Close()
+ t.Fatalf("open PTY slave: %v", err)
+ }
+ return master, slave
+}
diff --git a/internal/picker/terminal_other.go b/internal/picker/terminal_other.go
new file mode 100644
index 0000000..c320444
--- /dev/null
+++ b/internal/picker/terminal_other.go
@@ -0,0 +1,13 @@
+//go:build !linux
+
+package picker
+
+import "os"
+
+func newTerminalSession(_, _ *os.File) (session, error) {
+ return nil, ErrNotTerminal
+}
+
+func validateTerminalFiles(_, _ *os.File) error {
+ return ErrNotTerminal
+}
diff --git a/internal/sharelink/download.go b/internal/sharelink/download.go
new file mode 100644
index 0000000..c136b9a
--- /dev/null
+++ b/internal/sharelink/download.go
@@ -0,0 +1,115 @@
+package sharelink
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+
+ "github.com/Findddx/pku-drive-cli/internal/apperr"
+ "github.com/Findddx/pku-drive-cli/internal/download"
+)
+
+// Downloaded pairs a selected shared entry with its verified local result.
+type Downloaded struct {
+ Entry Entry
+ Result download.Result
+}
+
+// Download resolves and preflights the complete selection before starting any
+// transfer, then downloads files in the caller's stable input order. A batch
+// is not transactional, while every individual file is installed atomically.
+func (s *Session) Download(ctx context.Context, relativeFiles []string, localDir string, overwrite bool, progress download.Progress) (results []Downloaded, returnErr error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if len(relativeFiles) == 0 {
+ return nil, apperr.Wrap(apperr.Usage, "share download", "no files selected", errors.New("empty selection"))
+ }
+ entries := make([]Entry, len(relativeFiles))
+ seen := make(map[string]struct{}, len(relativeFiles))
+ var total int64
+ for index, relative := range relativeFiles {
+ if _, exists := seen[relative]; exists {
+ return nil, apperr.Wrap(apperr.Usage, "share download", "duplicate file selection", errors.New("duplicate relative path"))
+ }
+ seen[relative] = struct{}{}
+ entry, err := s.Resolve(ctx, relative)
+ if err != nil {
+ return nil, err
+ }
+ if entry.Item.Rev == "" || entry.Size < 0 || itemID(entry.Item) == "" {
+ return nil, invalidRemoteError("shared file identity is incomplete")
+ }
+ if entry.Size > math.MaxInt64-total {
+ return nil, invalidRemoteError("shared selection size is invalid")
+ }
+ total += entry.Size
+ entries[index] = entry
+ }
+
+ root, err := download.OpenDestinationRoot(localDir)
+ if err != nil {
+ return nil, apperr.Wrap(apperr.Local, "share download", "open destination directory", err)
+ }
+ defer func() {
+ // A cleanup failure must not turn already published, verified files into
+ // an apparent batch failure. Before the first publication, retain it.
+ if closeErr := root.Close(); returnErr == nil && len(results) == 0 && closeErr != nil {
+ results = nil
+ returnErr = apperr.Wrap(apperr.Local, "share download", "close destination directory", closeErr)
+ }
+ }()
+ for _, entry := range entries {
+ if err := root.Preflight(entry.Path, overwrite); err != nil {
+ return nil, apperr.Wrap(apperr.Local, "share download", "preflight destination", err)
+ }
+ }
+
+ if progress != nil {
+ progress.Started(total)
+ }
+ fileProgress := download.Progress(nil)
+ if progress != nil {
+ fileProgress = advancedOnlyProgress{parent: progress}
+ }
+ downloader := download.NewDownloader(s.api)
+ results = make([]Downloaded, 0, len(entries))
+ for _, entry := range entries {
+ result, err := downloader.GetAt(ctx, entry.Item, root, entry.Path, overwrite, fileProgress)
+ if err != nil {
+ classified := classifyAPIError(err)
+ if len(results) > 0 {
+ return results, partialDownloadError(classified, len(results))
+ }
+ return results, classified
+ }
+ results = append(results, Downloaded{Entry: entry, Result: result})
+ }
+ if progress != nil {
+ progress.Finished()
+ }
+ return results, nil
+}
+
+func partialDownloadError(cause error, completed int) error {
+ category := apperr.Network
+ var appErr *apperr.Error
+ if errors.As(cause, &appErr) && appErr != nil {
+ category = appErr.Category
+ }
+ message := fmt.Sprintf("download stopped after %d completed file(s); completed files remain in destination", completed)
+ return apperr.Wrap(category, "share download", message, cause)
+}
+
+type advancedOnlyProgress struct{ parent download.Progress }
+
+func (p advancedOnlyProgress) Started(int64) {}
+
+func (p advancedOnlyProgress) Advanced(delta int64) {
+ if p.parent != nil {
+ p.parent.Advanced(delta)
+ }
+}
+
+func (p advancedOnlyProgress) Finished() {}
diff --git a/internal/sharelink/errors.go b/internal/sharelink/errors.go
new file mode 100644
index 0000000..335430c
--- /dev/null
+++ b/internal/sharelink/errors.go
@@ -0,0 +1,101 @@
+// Package sharelink opens PKU AnyShare links as isolated, navigable sessions.
+package sharelink
+
+import (
+ "context"
+ "errors"
+
+ "github.com/Findddx/pku-drive-cli/internal/apperr"
+)
+
+var (
+ // ErrInvalidLink marks a URL that is not an exact same-origin share link.
+ ErrInvalidLink = errors.New("invalid share link")
+ // ErrAuthenticationRequired marks a real-name link that needs a saved login.
+ ErrAuthenticationRequired = errors.New("login required for shared link")
+ // ErrExpired marks a link whose configured expiry is in the past.
+ ErrExpired = errors.New("shared link expired")
+ // ErrPasswordRequired marks an anonymous link that requires a password.
+ ErrPasswordRequired = errors.New("shared link password required")
+ // ErrMobileVerificationRequired marks a link that requires mobile verification.
+ ErrMobileVerificationRequired = errors.New("shared link mobile verification required")
+ // ErrNotFound marks a missing shared link or relative entry.
+ ErrNotFound = errors.New("shared entry not found")
+ // ErrNotFile marks an entry that cannot be downloaded as a file.
+ ErrNotFile = errors.New("shared entry is not a file")
+ // ErrNotDirectory marks an entry that cannot be listed as a directory.
+ ErrNotDirectory = errors.New("shared entry is not a directory")
+)
+
+func invalidLinkError() error {
+ return apperr.Wrap(apperr.Usage, "share link", "invalid URL", ErrInvalidLink)
+}
+
+func invalidPathError() error {
+ return apperr.Wrap(apperr.Usage, "share path", "invalid relative path", errors.New("invalid path"))
+}
+
+func authRequiredError() error {
+ return apperr.Wrap(apperr.Auth, "share link", "login required", ErrAuthenticationRequired)
+}
+
+func passwordRequiredError() error {
+ return apperr.Wrap(apperr.Auth, "share link", "password-protected link", ErrPasswordRequired)
+}
+
+func mobileVerificationRequiredError() error {
+ return apperr.Wrap(apperr.Auth, "share link", "mobile verification required", ErrMobileVerificationRequired)
+}
+
+func expiredError() error {
+ return apperr.Wrap(apperr.Remote, "share link", "link expired", ErrExpired)
+}
+
+func notFoundError() error {
+ return apperr.Wrap(apperr.Remote, "share path", "entry not found", ErrNotFound)
+}
+
+func notFileError() error {
+ return apperr.Wrap(apperr.Usage, "share path", "entry is not a file", ErrNotFile)
+}
+
+func notDirectoryError() error {
+ return apperr.Wrap(apperr.Usage, "share path", "entry is not a directory", ErrNotDirectory)
+}
+
+func invalidRemoteError(message string) error {
+ return apperr.Wrap(apperr.Remote, "share link", message, errors.New("invalid server response"))
+}
+
+func requestError(ctx context.Context) error {
+ if ctx != nil {
+ if err := ctx.Err(); err != nil {
+ return apperr.Wrap(apperr.Interrupted, "share link", "request interrupted", err)
+ }
+ }
+ return apperr.Wrap(apperr.Network, "share link", "request failed", errors.New("transport error"))
+}
+
+func statusError(status int) error {
+ switch status {
+ case 401, 403:
+ return authRequiredError()
+ case 404:
+ return apperr.Wrap(apperr.Remote, "share link", "link not found", ErrNotFound)
+ case 410:
+ return expiredError()
+ default:
+ return apperr.Wrap(apperr.Remote, "share link", "server rejected request", errors.New("remote request failed"))
+ }
+}
+
+func classifyAPIError(err error) error {
+ if err == nil {
+ return nil
+ }
+ var appErr *apperr.Error
+ if errors.As(err, &appErr) && appErr != nil && appErr.Category == apperr.Auth {
+ return authRequiredError()
+ }
+ return err
+}
diff --git a/internal/sharelink/open.go b/internal/sharelink/open.go
new file mode 100644
index 0000000..2398c05
--- /dev/null
+++ b/internal/sharelink/open.go
@@ -0,0 +1,481 @@
+package sharelink
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Findddx/pku-drive-cli/internal/anyshare"
+ "github.com/Findddx/pku-drive-cli/internal/apperr"
+ "github.com/Findddx/pku-drive-cli/internal/httpx"
+)
+
+const maxLinkResponseBytes int64 = 1 << 20
+
+var opaqueLinkID = regexp.MustCompile(`^[A-Za-z0-9]+$`)
+
+// LinkType identifies the authorization model of a share.
+type LinkType string
+
+const (
+ LinkAnonymous LinkType = "anonymous"
+ LinkRealname LinkType = "realname"
+)
+
+// Opener creates a fresh, in-memory HTTP session for each share link.
+type Opener struct {
+ server string
+ origin *url.URL
+ httpClient *httpx.Client
+ authenticated *anyshare.Client
+}
+
+// NewOpener constructs a same-origin shared-link opener. authenticated may be
+// nil when only anonymous links are needed.
+func NewOpener(server string, httpClient *httpx.Client, authenticated *anyshare.Client) (*Opener, error) {
+ parsed, normalized, err := normalizeServer(server)
+ if err != nil || httpClient == nil || httpClient.HTTP == nil {
+ return nil, apperr.Wrap(apperr.Local, "share link", "invalid client configuration", errors.New("invalid configuration"))
+ }
+ return &Opener{server: normalized, origin: parsed, httpClient: httpClient, authenticated: authenticated}, nil
+}
+
+type metadataWire struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ Title string `json:"title"`
+ ExpiresAt json.RawMessage `json:"expires_at"`
+ PasswordRequired *bool `json:"password_required"`
+ VerifyMobile *bool `json:"verify_mobile"`
+ Item struct {
+ BelongsTo string `json:"belongs_to"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ } `json:"item"`
+}
+
+type landingInfo struct {
+ linkType string
+ itemType string
+ title string
+ expiresAt string
+ passwordRequired *bool
+ verifyMobile *bool
+}
+
+// Open validates rawLink, resolves its public metadata without OAuth, and
+// returns a session backed by either the ephemeral anonymous credential or the
+// caller's authenticated AnyShare client.
+func (o *Opener) Open(ctx context.Context, rawLink string) (*Session, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if o == nil || o.origin == nil || o.httpClient == nil || o.httpClient.HTTP == nil {
+ return nil, apperr.Wrap(apperr.Local, "share link", "client is not configured", errors.New("missing client dependency"))
+ }
+ linkURL, linkID, err := o.parseLink(rawLink)
+ if err != nil {
+ return nil, err
+ }
+ metadata, err := o.fetchMetadata(ctx, o.metadataClient(), linkID)
+ if err != nil {
+ return nil, err
+ }
+ linkType, rootType, title, err := validateMetadata(metadata, landingInfo{}, linkID)
+ if err != nil {
+ return nil, err
+ }
+ if err := restrictions(metadata, landingInfo{}); err != nil {
+ return nil, err
+ }
+
+ var api *anyshare.Client
+ var root anyshare.Item
+ switch linkType {
+ case LinkAnonymous:
+ privateClient, tokenCapture, err := o.privateClient(linkID)
+ if err != nil {
+ return nil, err
+ }
+ landing, err := o.fetchLanding(ctx, privateClient, linkURL)
+ if err != nil {
+ return nil, err
+ }
+ linkType, rootType, title, err = validateMetadata(metadata, landing, linkID)
+ if err != nil {
+ return nil, err
+ }
+ if err := restrictions(metadata, landing); err != nil {
+ return nil, err
+ }
+ token := tokenCapture.Token()
+ if token == "" {
+ return nil, authRequiredError()
+ }
+ // The legacy cookie only bootstraps the short-lived bearer token. Keep
+ // the browsing jar out of the API and signed-object transports.
+ api = anyshare.NewClient(o.server, o.metadataClient(), memoryToken(token))
+ items, err := api.EntryItems(ctx)
+ if err != nil {
+ return nil, classifyAPIError(err)
+ }
+ root, err = selectAnonymousRoot(items, metadata.Item.ID, rootType)
+ if err != nil {
+ return nil, err
+ }
+ case LinkRealname:
+ if o.authenticated == nil {
+ return nil, authRequiredError()
+ }
+ api = o.authenticated
+ if rootType == "file" {
+ root, err = api.FileMetadata(ctx, metadata.Item.ID, "")
+ if err != nil {
+ return nil, classifyAPIError(err)
+ }
+ if itemID(root) != metadata.Item.ID {
+ return nil, invalidRemoteError("file metadata did not match shared item")
+ }
+ } else {
+ root = anyshare.Item{ID: metadata.Item.ID, DocID: metadata.Item.ID, Name: title, Type: "directory", Size: -1}
+ }
+ default:
+ return nil, invalidRemoteError("unsupported link type")
+ }
+ rootEntry, err := makeRootEntry(root)
+ if err != nil {
+ return nil, err
+ }
+ return newSession(linkType, api, rootEntry), nil
+}
+
+// metadataClient deliberately has neither a cookie jar nor redirect support.
+// Resolving a link's public type must not depend on a browser session.
+func (o *Opener) metadataClient() *httpx.Client {
+ standard := *o.httpClient.HTTP
+ standard.Jar = nil
+ standard.CheckRedirect = func(*http.Request, []*http.Request) error {
+ return errors.New("metadata redirect rejected")
+ }
+ client := *o.httpClient
+ client.HTTP = &standard
+ return &client
+}
+
+func normalizeServer(raw string) (*url.URL, string, error) {
+ parsed, err := url.Parse(raw)
+ if err != nil || !parsed.IsAbs() || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || strings.HasSuffix(parsed.Host, ":") || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || parsed.Opaque != "" {
+ return nil, "", errors.New("invalid server")
+ }
+ parsed.Path = strings.TrimRight(parsed.Path, "/")
+ parsed.RawPath = ""
+ return parsed, strings.TrimRight(parsed.String(), "/"), nil
+}
+
+func (o *Opener) parseLink(raw string) (*url.URL, string, error) {
+ parsed, err := url.Parse(raw)
+ if err != nil || !parsed.IsAbs() || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || strings.HasSuffix(parsed.Host, ":") || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || parsed.Opaque != "" {
+ return nil, "", invalidLinkError()
+ }
+ if !sameOrigin(parsed, o.origin) || parsed.RawPath != "" || parsed.EscapedPath() != parsed.Path {
+ return nil, "", invalidLinkError()
+ }
+ const prefix = "/link/"
+ if !strings.HasPrefix(parsed.Path, prefix) {
+ return nil, "", invalidLinkError()
+ }
+ id := strings.TrimPrefix(parsed.Path, prefix)
+ if id == "" || len(id) > 256 || !opaqueLinkID.MatchString(id) {
+ return nil, "", invalidLinkError()
+ }
+ return parsed, id, nil
+}
+
+func (o *Opener) privateClient(linkID string) (*httpx.Client, *linkTokenTransport, error) {
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ return nil, nil, apperr.Wrap(apperr.Local, "share link", "create private session", errors.New("cookie jar unavailable"))
+ }
+ standard := *o.httpClient.HTTP
+ standard.Jar = jar
+ baseTransport := standard.Transport
+ if baseTransport == nil {
+ baseTransport = http.DefaultTransport
+ }
+ tokenCapture := &linkTokenTransport{
+ base: baseTransport,
+ origin: o.origin,
+ cookieName: "link_token:" + linkID,
+ }
+ standard.Transport = tokenCapture
+ standard.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 || !sameOrigin(req.URL, o.origin) {
+ return errors.New("redirect rejected")
+ }
+ return nil
+ }
+ private := *o.httpClient
+ private.HTTP = &standard
+ return &private, tokenCapture, nil
+}
+
+func (o *Opener) fetchLanding(ctx context.Context, client *httpx.Client, linkURL *url.URL) (landingInfo, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, linkURL.String(), nil)
+ if err != nil {
+ return landingInfo{}, invalidLinkError()
+ }
+ req.Header.Set("Accept", "text/html,application/xhtml+xml")
+ resp, err := client.Do(ctx, req, true)
+ if err != nil {
+ return landingInfo{}, requestError(ctx)
+ }
+ defer resp.Body.Close()
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return landingInfo{}, statusError(resp.StatusCode)
+ }
+ if resp.Request == nil || resp.Request.URL == nil || !sameOrigin(resp.Request.URL, o.origin) {
+ return landingInfo{}, invalidRemoteError("invalid landing response")
+ }
+ return parseLanding(resp.Request.URL.Query())
+}
+
+func (o *Opener) fetchMetadata(ctx context.Context, client *httpx.Client, linkID string) (metadataWire, error) {
+ endpoint := o.server + "/api/shared-link/v1/links/" + url.PathEscape(linkID)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return metadataWire{}, invalidRemoteError("could not build metadata request")
+ }
+ req.Header.Set("Accept", "application/json")
+ resp, err := client.Do(ctx, req, true)
+ if err != nil {
+ return metadataWire{}, requestError(ctx)
+ }
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ drainAndClose(resp.Body)
+ return metadataWire{}, statusError(resp.StatusCode)
+ }
+ var metadata metadataWire
+ if err := httpx.DecodeJSON(resp, maxLinkResponseBytes, &metadata); err != nil {
+ return metadataWire{}, invalidRemoteError("invalid metadata response")
+ }
+ return metadata, nil
+}
+
+func parseLanding(values url.Values) (landingInfo, error) {
+ for _, key := range []string{"type", "item_type", "title", "expires_at", "password_required", "verify_mobile"} {
+ if all, ok := values[key]; ok && len(all) != 1 {
+ return landingInfo{}, invalidRemoteError("ambiguous landing metadata")
+ }
+ }
+ result := landingInfo{
+ linkType: values.Get("type"),
+ itemType: values.Get("item_type"),
+ title: values.Get("title"),
+ expiresAt: values.Get("expires_at"),
+ }
+ var err error
+ if raw, ok, valueErr := singleQueryValue(values, "password_required"); valueErr != nil {
+ err = invalidRemoteError("invalid landing restrictions")
+ } else if ok {
+ value, parseErr := strconv.ParseBool(raw)
+ if parseErr != nil {
+ err = invalidRemoteError("invalid landing restrictions")
+ } else {
+ result.passwordRequired = &value
+ }
+ }
+ if raw, ok, valueErr := singleQueryValue(values, "verify_mobile"); valueErr != nil {
+ err = invalidRemoteError("invalid landing restrictions")
+ } else if ok {
+ value, parseErr := strconv.ParseBool(raw)
+ if parseErr != nil {
+ err = invalidRemoteError("invalid landing restrictions")
+ } else {
+ result.verifyMobile = &value
+ }
+ }
+ return result, err
+}
+
+func singleQueryValue(values url.Values, key string) (string, bool, error) {
+ all, ok := values[key]
+ if !ok {
+ return "", false, nil
+ }
+ if len(all) != 1 || all[0] == "" {
+ return "", false, errors.New("invalid query value")
+ }
+ return all[0], true, nil
+}
+
+func validateMetadata(metadata metadataWire, landing landingInfo, linkID string) (LinkType, string, string, error) {
+ if metadata.ID != linkID || metadata.Item.BelongsTo != "document" || (metadata.Item.ID != "" && !strings.HasPrefix(metadata.Item.ID, "gns://")) {
+ return "", "", "", invalidRemoteError("invalid shared item metadata")
+ }
+ linkType := LinkType(metadata.Type)
+ if linkType != LinkAnonymous && linkType != LinkRealname {
+ return "", "", "", invalidRemoteError("unsupported link type")
+ }
+ if linkType == LinkRealname && metadata.Item.ID == "" {
+ return "", "", "", invalidRemoteError("real-name shared item identity is missing")
+ }
+ if landing.linkType != "" && landing.linkType != metadata.Type {
+ return "", "", "", invalidRemoteError("inconsistent link type")
+ }
+ rootType, ok := normalizeItemType(metadata.Item.Type)
+ if !ok {
+ return "", "", "", invalidRemoteError("unsupported shared item type")
+ }
+ if landing.itemType != "" {
+ landingType, valid := normalizeItemType(landing.itemType)
+ if !valid || landingType != rootType {
+ return "", "", "", invalidRemoteError("inconsistent shared item type")
+ }
+ }
+ title := metadata.Title
+ if title == "" {
+ title = metadata.Item.Name
+ }
+ if title == "" {
+ title = landing.title
+ }
+ return linkType, rootType, title, nil
+}
+
+func restrictions(metadata metadataWire, landing landingInfo) error {
+ if metadata.PasswordRequired != nil && *metadata.PasswordRequired || landing.passwordRequired != nil && *landing.passwordRequired {
+ return passwordRequiredError()
+ }
+ if metadata.VerifyMobile != nil && *metadata.VerifyMobile || landing.verifyMobile != nil && *landing.verifyMobile {
+ return mobileVerificationRequiredError()
+ }
+ metadataExpiry, metadataPresent, err := parseExpiry(metadata.ExpiresAt, "")
+ if err != nil {
+ return invalidRemoteError("invalid link expiry")
+ }
+ landingExpiry, landingPresent, err := parseExpiry(nil, landing.expiresAt)
+ if err != nil {
+ return invalidRemoteError("invalid link expiry")
+ }
+ now := time.Now()
+ // The documented Unix epoch value denotes a link with no expiry.
+ if metadataPresent && metadataExpiry.After(time.Unix(0, 0)) && metadataExpiry.Before(now) || landingPresent && landingExpiry.After(time.Unix(0, 0)) && landingExpiry.Before(now) {
+ return expiredError()
+ }
+ return nil
+}
+
+func parseExpiry(raw json.RawMessage, fallback string) (time.Time, bool, error) {
+ value := ""
+ if len(raw) > 0 && string(raw) != "null" {
+ if raw[0] == '"' {
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return time.Time{}, false, err
+ }
+ } else {
+ value = string(raw)
+ }
+ } else {
+ value = fallback
+ }
+ if value == "" {
+ return time.Time{}, false, nil
+ }
+ if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
+ return time.Unix(seconds, 0), true, nil
+ }
+ parsed, err := time.Parse(time.RFC3339, value)
+ if err != nil {
+ return time.Time{}, false, err
+ }
+ return parsed, true, nil
+}
+
+func selectAnonymousRoot(items []anyshare.Item, expectedID, expectedType string) (anyshare.Item, error) {
+ var root anyshare.Item
+ matches := 0
+ if expectedID == "" {
+ if len(items) != 1 {
+ return anyshare.Item{}, invalidRemoteError("anonymous entry root is ambiguous")
+ }
+ root = items[0]
+ matches = 1
+ } else {
+ for _, item := range items {
+ if itemID(item) == expectedID {
+ root = item
+ matches++
+ }
+ }
+ }
+ if matches == 0 {
+ return anyshare.Item{}, notFoundError()
+ }
+ if matches != 1 {
+ return anyshare.Item{}, invalidRemoteError("ambiguous anonymous entry")
+ }
+ typeName, ok := normalizeItemType(root.Type)
+ if !ok || typeName != expectedType {
+ return anyshare.Item{}, invalidRemoteError("anonymous entry did not match shared item")
+ }
+ root.Type = typeName
+ if itemID(root) == "" {
+ return anyshare.Item{}, invalidRemoteError("anonymous entry identity is missing")
+ }
+ return root, nil
+}
+
+func normalizeItemType(value string) (string, bool) {
+ switch value {
+ case "file":
+ return "file", true
+ case "folder", "directory":
+ return "directory", true
+ default:
+ return "", false
+ }
+}
+
+func itemID(item anyshare.Item) string {
+ if item.ID != "" {
+ return item.ID
+ }
+ return item.DocID
+}
+
+func sameOrigin(left, right *url.URL) bool {
+ return left != nil && right != nil && left.Scheme == "https" && right.Scheme == "https" &&
+ strings.EqualFold(left.Hostname(), right.Hostname()) && effectiveHTTPSPort(left) == effectiveHTTPSPort(right)
+}
+
+func effectiveHTTPSPort(value *url.URL) string {
+ if value == nil {
+ return ""
+ }
+ if port := value.Port(); port != "" {
+ return port
+ }
+ return "443"
+}
+
+func drainAndClose(body io.ReadCloser) {
+ if body == nil {
+ return
+ }
+ _, _ = io.Copy(io.Discard, io.LimitReader(body, 64<<10))
+ _ = body.Close()
+}
+
+type memoryToken string
+
+func (t memoryToken) Token(context.Context, bool) (string, error) { return string(t), nil }
diff --git a/internal/sharelink/open_internal_test.go b/internal/sharelink/open_internal_test.go
new file mode 100644
index 0000000..a4d1e1b
--- /dev/null
+++ b/internal/sharelink/open_internal_test.go
@@ -0,0 +1,28 @@
+package sharelink
+
+import (
+ "net/url"
+ "testing"
+)
+
+func TestSameOriginTreatsDefaultHTTPSPortAsEquivalent(t *testing.T) {
+ withoutPort, _ := url.Parse("https://disk.example")
+ withDefaultPort, _ := url.Parse("https://DISK.example:443")
+ withOtherPort, _ := url.Parse("https://disk.example:444")
+ if !sameOrigin(withoutPort, withDefaultPort) {
+ t.Fatal("default HTTPS port should be the same origin")
+ }
+ if sameOrigin(withoutPort, withOtherPort) {
+ t.Fatal("non-default port should be a different origin")
+ }
+}
+
+func TestParseLandingRejectsDuplicateRecognizedParameters(t *testing.T) {
+ for _, key := range []string{"type", "item_type", "title", "expires_at", "password_required", "verify_mobile"} {
+ values := url.Values{key: []string{"first", "second"}}
+ _, err := parseLanding(values)
+ if err == nil {
+ t.Fatalf("parseLanding duplicate %s error = nil", key)
+ }
+ }
+}
diff --git a/internal/sharelink/session.go b/internal/sharelink/session.go
new file mode 100644
index 0000000..586d108
--- /dev/null
+++ b/internal/sharelink/session.go
@@ -0,0 +1,237 @@
+package sharelink
+
+import (
+ "context"
+ "sort"
+ "strings"
+ "sync"
+
+ "github.com/Findddx/pku-drive-cli/internal/anyshare"
+)
+
+// Entry is a shared item addressed by a slash-separated path relative to the
+// shared folder. Item retains the exact AnyShare identity used for download.
+type Entry struct {
+ Path string
+ Name string
+ Type string
+ Size int64
+ Item anyshare.Item
+}
+
+// Session is one isolated share-link browsing session.
+type Session struct {
+ linkType LinkType
+ api *anyshare.Client
+ root Entry
+
+ mu sync.RWMutex
+ cache map[string][]Entry
+}
+
+func newSession(linkType LinkType, api *anyshare.Client, root Entry) *Session {
+ return &Session{linkType: linkType, api: api, root: root, cache: make(map[string][]Entry)}
+}
+
+// Type returns the share's authorization model.
+func (s *Session) Type() LinkType {
+ if s == nil {
+ return ""
+ }
+ return s.linkType
+}
+
+// Root returns the exact shared file or directory root.
+func (s *Session) Root() Entry {
+ if s == nil {
+ return Entry{}
+ }
+ return s.root
+}
+
+// List returns the immediate children of relativeDir. An empty path names the
+// shared directory root; nested directories are loaded only when requested.
+func (s *Session) List(ctx context.Context, relativeDir string) ([]Entry, error) {
+ parts, err := splitRelative(relativeDir, true)
+ if err != nil {
+ return nil, err
+ }
+ if s == nil || s.api == nil {
+ return nil, invalidRemoteError("shared session is unavailable")
+ }
+ key := strings.Join(parts, "/")
+ if entries, ok := s.cached(key); ok {
+ return entries, nil
+ }
+ directory := s.root
+ if len(parts) > 0 {
+ directory, err = s.resolveParts(ctx, parts)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if directory.Type != "directory" {
+ return nil, notDirectoryError()
+ }
+ items, err := s.api.ListFolder(ctx, itemID(directory.Item))
+ if err != nil {
+ return nil, classifyAPIError(err)
+ }
+ entries := make([]Entry, 0, len(items))
+ names := make(map[string]struct{}, len(items))
+ for _, item := range items {
+ entry, err := makeChildEntry(key, item)
+ if err != nil {
+ return nil, err
+ }
+ if _, exists := names[entry.Name]; exists {
+ return nil, invalidRemoteError("ambiguous directory entry")
+ }
+ names[entry.Name] = struct{}{}
+ entries = append(entries, entry)
+ }
+ sort.SliceStable(entries, func(i, j int) bool {
+ if entries[i].Type == "directory" && entries[j].Type != "directory" {
+ return true
+ }
+ if entries[i].Type != "directory" && entries[j].Type == "directory" {
+ return false
+ }
+ return entries[i].Name < entries[j].Name
+ })
+ s.store(key, entries)
+ return cloneEntries(entries), nil
+}
+
+// Resolve resolves one strict relative file path without recursively expanding
+// unrelated directories.
+func (s *Session) Resolve(ctx context.Context, relativeFile string) (Entry, error) {
+ parts, err := splitRelative(relativeFile, false)
+ if err != nil {
+ return Entry{}, err
+ }
+ if s == nil || s.api == nil {
+ return Entry{}, invalidRemoteError("shared session is unavailable")
+ }
+ entry, err := s.resolveParts(ctx, parts)
+ if err != nil {
+ return Entry{}, err
+ }
+ if entry.Type != "file" {
+ return Entry{}, notFileError()
+ }
+ return entry, nil
+}
+
+func (s *Session) resolveParts(ctx context.Context, parts []string) (Entry, error) {
+ if s.root.Type == "file" {
+ if len(parts) == 1 && parts[0] == s.root.Name {
+ return s.root, nil
+ }
+ return Entry{}, notFoundError()
+ }
+ currentDir := ""
+ var current Entry
+ for index, name := range parts {
+ children, err := s.List(ctx, currentDir)
+ if err != nil {
+ return Entry{}, err
+ }
+ matches := 0
+ for _, child := range children {
+ if child.Name == name {
+ current = child
+ matches++
+ }
+ }
+ if matches == 0 {
+ return Entry{}, notFoundError()
+ }
+ if matches != 1 {
+ return Entry{}, invalidRemoteError("ambiguous directory entry")
+ }
+ if index+1 < len(parts) {
+ if current.Type != "directory" {
+ return Entry{}, notDirectoryError()
+ }
+ currentDir = current.Path
+ }
+ }
+ return current, nil
+}
+
+func makeRootEntry(item anyshare.Item) (Entry, error) {
+ typeName, ok := normalizeItemType(item.Type)
+ if !ok || !validItemIdentity(itemID(item)) {
+ return Entry{}, invalidRemoteError("invalid shared root")
+ }
+ item.Type = typeName
+ entry := Entry{Name: item.Name, Type: typeName, Size: item.Size, Item: item}
+ if typeName == "file" {
+ if !validSegment(item.Name) {
+ return Entry{}, invalidRemoteError("invalid shared file name")
+ }
+ entry.Path = item.Name
+ }
+ return entry, nil
+}
+
+func makeChildEntry(parent string, item anyshare.Item) (Entry, error) {
+ typeName, ok := normalizeItemType(item.Type)
+ if !ok || !validItemIdentity(itemID(item)) || !validSegment(item.Name) {
+ return Entry{}, invalidRemoteError("invalid shared directory entry")
+ }
+ item.Type = typeName
+ path := item.Name
+ if parent != "" {
+ path = parent + "/" + item.Name
+ }
+ return Entry{Path: path, Name: item.Name, Type: typeName, Size: item.Size, Item: item}, nil
+}
+
+func splitRelative(value string, allowRoot bool) ([]string, error) {
+ if value == "" {
+ if allowRoot {
+ return nil, nil
+ }
+ return nil, invalidPathError()
+ }
+ if strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || strings.Contains(value, "//") || strings.Contains(value, `\`) || strings.IndexByte(value, 0) >= 0 {
+ return nil, invalidPathError()
+ }
+ parts := strings.Split(value, "/")
+ for _, part := range parts {
+ if !validSegment(part) {
+ return nil, invalidPathError()
+ }
+ }
+ return parts, nil
+}
+
+func validSegment(value string) bool {
+ return value != "" && value != "." && value != ".." && !strings.ContainsAny(value, "/\\\x00")
+}
+
+func validItemIdentity(value string) bool {
+ return strings.HasPrefix(value, "gns://") && len(value) > len("gns://")
+}
+
+func (s *Session) cached(key string) ([]Entry, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ entries, ok := s.cache[key]
+ return cloneEntries(entries), ok
+}
+
+func (s *Session) store(key string, entries []Entry) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.cache[key] = cloneEntries(entries)
+}
+
+func cloneEntries(entries []Entry) []Entry {
+ if entries == nil {
+ return nil
+ }
+ return append([]Entry(nil), entries...)
+}
diff --git a/internal/sharelink/sharelink_test.go b/internal/sharelink/sharelink_test.go
new file mode 100644
index 0000000..e740b36
--- /dev/null
+++ b/internal/sharelink/sharelink_test.go
@@ -0,0 +1,579 @@
+package sharelink_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/Findddx/pku-drive-cli/internal/anyshare"
+ "github.com/Findddx/pku-drive-cli/internal/apperr"
+ "github.com/Findddx/pku-drive-cli/internal/httpx"
+ "github.com/Findddx/pku-drive-cli/internal/sharelink"
+)
+
+type staticToken string
+
+func (s staticToken) Token(context.Context, bool) (string, error) { return string(s), nil }
+
+func TestAnonymousSessionKeepsLinkTokenPrivateAndResolvesNestedFiles(t *testing.T) {
+ const (
+ linkID = "AA0123456789ABCDEF"
+ secret = "anonymous-link-token-secret"
+ )
+ var mu sync.Mutex
+ var requests []string
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ requests = append(requests, r.Method+" "+r.RequestURI)
+ mu.Unlock()
+ switch r.RequestURI {
+ case "/link/" + linkID:
+ assertNoAuthorization(t, r)
+ setLegacyLinkCookie(w, linkID, secret)
+ http.Redirect(w, r, "/anyshare/link/"+linkID+"?type=anonymous&item_type=folder&password_required=false&verify_mobile=false&title=Fixture", http.StatusFound)
+ case "/anyshare/link/" + linkID + "?type=anonymous&item_type=folder&password_required=false&verify_mobile=false&title=Fixture":
+ assertNoAuthorization(t, r)
+ assertCookie(t, r, "link_token:"+linkID, secret)
+ _, _ = io.WriteString(w, "landing")
+ case "/api/shared-link/v1/links/" + linkID:
+ assertNoAuthorization(t, r)
+ if r.Header.Get("Cookie") != "" {
+ t.Fatalf("metadata cookie = %q, want empty", r.Header.Get("Cookie"))
+ }
+ // PKU's deployed anonymous resolver omits item.id; the exact root
+ // identity is returned by /api/efast/v1/entry-item.
+ _, _ = io.WriteString(w, `{"type":"anonymous","id":"`+linkID+`","item":{"belongs_to":"document","type":"folder"}}`)
+ case "/api/efast/v1/entry-item":
+ assertBearer(t, r, secret)
+ assertNoCookie(t, r)
+ _, _ = io.WriteString(w, `[{"id":"gns://shared-root","name":"Fixture","type":"folder","rev":"root-r1","size":-1}]`)
+ case "/api/efast/v1/folders/gns:%2F%2Fshared-root/sub_objects?limit=1000":
+ assertBearer(t, r, secret)
+ assertNoCookie(t, r)
+ _, _ = io.WriteString(w, `{"dirs":[{"id":"gns://nested","name":"nested","type":"folder","size":-1}],"files":[{"id":"gns://z-file","name":"z.tsv","type":"file","rev":"z-r1","size":9}],"next_marker":"page two"}`)
+ case "/api/efast/v1/folders/gns:%2F%2Fshared-root/sub_objects?limit=1000&marker=page+two":
+ assertBearer(t, r, secret)
+ assertNoCookie(t, r)
+ _, _ = io.WriteString(w, `{"dirs":[],"files":[{"id":"gns://a-file","name":"a.tsv","type":"file","rev":"a-r1","size":3}],"next_marker":""}`)
+ case "/api/efast/v1/folders/gns:%2F%2Fnested/sub_objects?limit=1000":
+ assertBearer(t, r, secret)
+ assertNoCookie(t, r)
+ _, _ = io.WriteString(w, `{"dirs":[],"files":[{"docid":"gns://deep-file","name":"deep.bin","type":"file","rev":"deep-r2","size":21}],"next_marker":""}`)
+ default:
+ http.Error(w, "unexpected", http.StatusNotFound)
+ }
+ }))
+ defer server.Close()
+
+ opener := newOpener(t, server, nil)
+ session, err := opener.Open(context.Background(), server.URL+"/link/"+linkID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if session.Type() != sharelink.LinkAnonymous {
+ t.Fatalf("type = %q", session.Type())
+ }
+ wantRoot := sharelink.Entry{
+ Name: "Fixture", Type: "directory", Size: -1,
+ Item: anyshare.Item{ID: "gns://shared-root", Name: "Fixture", Type: "directory", Rev: "root-r1", Size: -1},
+ }
+ if got := session.Root(); !reflect.DeepEqual(got, wantRoot) {
+ t.Fatalf("root = %#v, want %#v", got, wantRoot)
+ }
+ entries, err := session.List(context.Background(), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantPaths := []string{"nested", "a.tsv", "z.tsv"}
+ gotPaths := make([]string, len(entries))
+ for i := range entries {
+ gotPaths[i] = entries[i].Path
+ }
+ if !reflect.DeepEqual(gotPaths, wantPaths) {
+ t.Fatalf("paths = %q, want %q", gotPaths, wantPaths)
+ }
+ deep, err := session.Resolve(context.Background(), "nested/deep.bin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if deep.Path != "nested/deep.bin" || deep.Item.ID != "gns://deep-file" || deep.Item.Rev != "deep-r2" {
+ t.Fatalf("deep = %#v", deep)
+ }
+ if strings.Contains(fmt.Sprint(requests), secret) {
+ t.Fatalf("request targets leaked token: %v", requests)
+ }
+ if len(requests) == 0 || requests[0] != "GET /api/shared-link/v1/links/"+linkID {
+ t.Fatalf("first request = %q, want unauthenticated metadata resolution", requests)
+ }
+}
+
+func TestRealnameFileUsesAuthenticatedClient(t *testing.T) {
+ const linkID = "AR0123456789ABCDEF"
+ var landingRequests atomic.Int64
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.EscapedPath() {
+ case "/link/" + linkID:
+ landingRequests.Add(1)
+ http.Error(w, "real-name landing must not be opened", http.StatusInternalServerError)
+ case "/anyshare/link/" + linkID:
+ landingRequests.Add(1)
+ http.Error(w, "real-name landing must not be opened", http.StatusInternalServerError)
+ case "/api/shared-link/v1/links/" + linkID:
+ assertNoAuthorization(t, r)
+ _, _ = io.WriteString(w, `{"type":"realname","id":"`+linkID+`","item":{"belongs_to":"document","id":"gns://real-file","type":"file"}}`)
+ case "/api/efast/v1/file/metadata":
+ assertBearer(t, r, "oauth-access")
+ body, _ := io.ReadAll(r.Body)
+ if string(body) != `{"docid":"gns://real-file"}` {
+ t.Fatalf("body = %s", body)
+ }
+ _, _ = io.WriteString(w, `{"docid":"gns://real-file","name":"report.tsv","rev":"real-r1","type":"file","size":41}`)
+ default:
+ http.Error(w, "unexpected", http.StatusNotFound)
+ }
+ }))
+ defer server.Close()
+
+ transport := httpx.New(server.Client().Transport, httpx.Policy{MaxAttempts: 1})
+ authenticated := anyshare.NewClient(server.URL, transport, staticToken("oauth-access"))
+ opener, err := sharelink.NewOpener(server.URL, transport, authenticated)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, err := opener.Open(context.Background(), server.URL+"/link/"+linkID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := sharelink.Entry{
+ Path: "report.tsv", Name: "report.tsv", Type: "file", Size: 41,
+ Item: anyshare.Item{ID: "gns://real-file", DocID: "gns://real-file", Name: "report.tsv", Type: "file", Rev: "real-r1", Size: 41},
+ }
+ if got := session.Root(); !reflect.DeepEqual(got, want) {
+ t.Fatalf("root = %#v, want %#v", got, want)
+ }
+ if landingRequests.Load() != 0 {
+ t.Fatalf("real-name landing requests = %d, want 0", landingRequests.Load())
+ }
+ resolved, err := session.Resolve(context.Background(), "report.tsv")
+ if err != nil || !reflect.DeepEqual(resolved, want) {
+ t.Fatalf("Resolve() = %#v, %v", resolved, err)
+ }
+}
+
+func TestRealnameLinkClassifiesMissingLogin(t *testing.T) {
+ const linkID = "ARLOGINREQUIRED"
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.EscapedPath() {
+ case "/api/shared-link/v1/links/" + linkID:
+ _, _ = io.WriteString(w, `{"type":"realname","id":"`+linkID+`","item":{"belongs_to":"document","id":"gns://private-file","type":"file"}}`)
+ default:
+ http.Error(w, "unexpected", http.StatusNotFound)
+ }
+ }))
+ defer server.Close()
+
+ transport := httpx.New(server.Client().Transport, httpx.Policy{MaxAttempts: 1})
+ authenticated := anyshare.NewClient(server.URL, transport, staticToken(""))
+ opener, err := sharelink.NewOpener(server.URL, transport, authenticated)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = opener.Open(context.Background(), server.URL+"/link/"+linkID)
+ if !errors.Is(err, sharelink.ErrAuthenticationRequired) {
+ t.Fatalf("err = %v, want authentication required", err)
+ }
+ var appErr *apperr.Error
+ if !errors.As(err, &appErr) || appErr.Category != apperr.Auth {
+ t.Fatalf("err = %v, want auth category", err)
+ }
+ if strings.Contains(err.Error(), linkID) || strings.Contains(err.Error(), "private-file") {
+ t.Fatalf("error leaked link data: %v", err)
+ }
+}
+
+func TestOpenRejectsInvalidAndCrossOriginLinksWithoutDisclosure(t *testing.T) {
+ requests := 0
+ server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ }))
+ defer server.Close()
+ opener := newOpener(t, server, nil)
+ host := strings.TrimPrefix(server.URL, "https://")
+ tests := []string{
+ "http://" + host + "/link/plaintextsecret",
+ "https://userinfosecret@" + host + "/link/userinfosecret",
+ server.URL + "/link/pathsecret/extra",
+ server.URL + "/link/encoded%2Fslashsecret",
+ server.URL + "/link/querysecret?token=query-secret",
+ server.URL + "/link/fragmentsecret#fragment-secret",
+ server.URL + "/link/not_opaque",
+ "https://example.invalid/link/crossoriginsecret",
+ }
+ for _, raw := range tests {
+ _, err := opener.Open(context.Background(), raw)
+ if !errors.Is(err, sharelink.ErrInvalidLink) {
+ t.Fatalf("Open(%q) err = %v, want invalid link", raw, err)
+ }
+ for _, marker := range []string{"secret", host, raw} {
+ if marker != "" && strings.Contains(err.Error(), marker) {
+ t.Fatalf("error disclosed rejected input: %v", err)
+ }
+ }
+ }
+ if requests != 0 {
+ t.Fatalf("requests = %d, want 0", requests)
+ }
+}
+
+func TestOpenRejectsCrossOriginRedirectBeforeSendingCookieOrToken(t *testing.T) {
+ const (
+ linkID = "AACROSSORIGIN"
+ secret = "redirect-cookie-secret"
+ )
+ evilRequests := 0
+ evil := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { evilRequests++ }))
+ defer evil.Close()
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.EscapedPath() {
+ case "/api/shared-link/v1/links/" + linkID:
+ assertNoAuthorization(t, r)
+ _, _ = io.WriteString(w, `{"type":"anonymous","id":"`+linkID+`","item":{"belongs_to":"document","type":"folder"}}`)
+ case "/link/" + linkID:
+ setLegacyLinkCookie(w, linkID, secret)
+ http.Redirect(w, r, evil.URL+"/steal/"+linkID, http.StatusFound)
+ default:
+ http.Error(w, "unexpected", http.StatusNotFound)
+ }
+ }))
+ defer server.Close()
+
+ opener := newOpener(t, server, nil)
+ _, err := opener.Open(context.Background(), server.URL+"/link/"+linkID)
+ if err == nil {
+ t.Fatal("Open() error = nil")
+ }
+ if evilRequests != 0 {
+ t.Fatalf("cross-origin requests = %d, want 0", evilRequests)
+ }
+ if strings.Contains(err.Error(), linkID) || strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), evil.URL) {
+ t.Fatalf("error leaked redirect data: %v", err)
+ }
+}
+
+func TestOpenStopsBeforeAnonymousAPIForRestrictedOrExpiredLinks(t *testing.T) {
+ tests := []struct {
+ name string
+ query string
+ want error
+ }{
+ {name: "expired", query: "expires_at=2000-01-01T00%3A00%3A00Z", want: sharelink.ErrExpired},
+ {name: "password", query: "password_required=true", want: sharelink.ErrPasswordRequired},
+ {name: "mobile", query: "verify_mobile=true", want: sharelink.ErrMobileVerificationRequired},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ const linkID = "AARESTRICTED"
+ entryRequests := 0
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.EscapedPath() {
+ case "/link/" + linkID:
+ setLegacyLinkCookie(w, linkID, "ephemeral-secret")
+ http.Redirect(w, r, "/anyshare/link/"+linkID+"?type=anonymous&item_type=folder&"+tt.query, http.StatusFound)
+ case "/anyshare/link/" + linkID:
+ _, _ = io.WriteString(w, "landing")
+ case "/api/shared-link/v1/links/" + linkID:
+ _, _ = io.WriteString(w, `{"type":"anonymous","id":"`+linkID+`","item":{"belongs_to":"document","id":"gns://root","type":"folder"}}`)
+ case "/api/efast/v1/entry-item":
+ entryRequests++
+ default:
+ http.Error(w, "unexpected", http.StatusNotFound)
+ }
+ }))
+ defer server.Close()
+
+ _, err := newOpener(t, server, nil).Open(context.Background(), server.URL+"/link/"+linkID)
+ if !errors.Is(err, tt.want) {
+ t.Fatalf("err = %v, want %v", err, tt.want)
+ }
+ if entryRequests != 0 {
+ t.Fatalf("entry requests = %d, want 0", entryRequests)
+ }
+ })
+ }
+}
+
+func TestSessionRejectsUnsafeRelativePathsBeforeNetworkAccess(t *testing.T) {
+ session, server, listRequests := openFolderFixture(t)
+ defer server.Close()
+ for _, path := range []string{"/absolute", "trailing/", "double//slash", "./file", "dir/../file", `dir\file`, "nul\x00file"} {
+ _, err := session.Resolve(context.Background(), path)
+ if err == nil {
+ t.Fatalf("Resolve(%q) error = nil", path)
+ }
+ var appErr *apperr.Error
+ if !errors.As(err, &appErr) || appErr.Category != apperr.Usage {
+ t.Fatalf("Resolve(%q) err = %v, want usage", path, err)
+ }
+ }
+ if *listRequests != 0 {
+ t.Fatalf("list requests = %d, want 0", *listRequests)
+ }
+}
+
+func TestDownloadPreflightsEveryDestinationBeforeTransfer(t *testing.T) {
+ const linkID = "ARPRECHECKBATCH"
+ metadataRequests := 0
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.EscapedPath() {
+ case "/link/" + linkID:
+ http.Redirect(w, r, "/anyshare/link/"+linkID+"?type=realname&item_type=folder", http.StatusFound)
+ case "/anyshare/link/" + linkID:
+ _, _ = io.WriteString(w, "landing")
+ case "/api/shared-link/v1/links/" + linkID:
+ _, _ = io.WriteString(w, `{"type":"realname","id":"`+linkID+`","item":{"belongs_to":"document","id":"gns://root","type":"folder"}}`)
+ case "/api/efast/v1/folders/gns:%2F%2Froot/sub_objects":
+ _, _ = io.WriteString(w, `{"dirs":[],"files":[{"id":"gns://a","name":"a.bin","type":"file","rev":"a-r1","size":1},{"id":"gns://b","name":"b.bin","type":"file","rev":"b-r1","size":1}],"next_marker":""}`)
+ case "/api/efast/v1/file/metadata":
+ metadataRequests++
+ http.Error(w, "must not transfer", http.StatusInternalServerError)
+ default:
+ http.Error(w, "unexpected", http.StatusNotFound)
+ }
+ }))
+ defer server.Close()
+ transport := httpx.New(server.Client().Transport, httpx.Policy{MaxAttempts: 1})
+ authenticated := anyshare.NewClient(server.URL, transport, staticToken("oauth-access"))
+ opener, err := sharelink.NewOpener(server.URL, transport, authenticated)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, err := opener.Open(context.Background(), server.URL+"/link/"+linkID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ destination := t.TempDir()
+ if err := os.WriteFile(filepath.Join(destination, "b.bin"), []byte("existing"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _, err = session.Download(context.Background(), []string{"a.bin", "b.bin"}, destination, false, nil)
+ var appErr *apperr.Error
+ if !errors.As(err, &appErr) || appErr.Category != apperr.Local {
+ t.Fatalf("err = %v, want local preflight error", err)
+ }
+ if metadataRequests != 0 {
+ t.Fatalf("metadata requests = %d, want 0", metadataRequests)
+ }
+ if _, err := os.Stat(filepath.Join(destination, "a.bin")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a.bin err = %v, want not exist", err)
+ }
+}
+
+func TestDownloadPreservesRelativeLayoutOrderAndAggregateProgress(t *testing.T) {
+ const linkID = "ARDOWNLOADBATCH"
+ payloads := map[string]string{"gns://a": "AAA", "gns://b": "BBBBB"}
+ var failB atomic.Bool
+ server := httptest.NewUnstartedServer(nil)
+ server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.EscapedPath() {
+ case "/link/" + linkID:
+ assertNoAuthorization(t, r)
+ http.Redirect(w, r, "/anyshare/link/"+linkID+"?type=realname&item_type=folder&title=Batch", http.StatusFound)
+ case "/anyshare/link/" + linkID:
+ assertNoAuthorization(t, r)
+ _, _ = io.WriteString(w, "landing")
+ case "/api/shared-link/v1/links/" + linkID:
+ assertNoAuthorization(t, r)
+ _, _ = io.WriteString(w, `{"type":"realname","id":"`+linkID+`","item":{"belongs_to":"document","id":"gns://root","type":"folder"}}`)
+ case "/api/efast/v1/folders/gns:%2F%2Froot/sub_objects":
+ assertBearer(t, r, "oauth-access")
+ _, _ = io.WriteString(w, `{"dirs":[{"id":"gns://nested","name":"nested","type":"folder","size":-1}],"files":[{"id":"gns://a","name":"a.bin","type":"file","rev":"a-r1","size":3}],"next_marker":""}`)
+ case "/api/efast/v1/folders/gns:%2F%2Fnested/sub_objects":
+ assertBearer(t, r, "oauth-access")
+ _, _ = io.WriteString(w, `{"dirs":[],"files":[{"id":"gns://b","name":"b.bin","type":"file","rev":"b-r1","size":5}],"next_marker":""}`)
+ case "/api/efast/v1/file/metadata":
+ assertBearer(t, r, "oauth-access")
+ body, _ := io.ReadAll(r.Body)
+ var id, name, rev string
+ switch string(body) {
+ case `{"docid":"gns://a","rev":"a-r1"}`:
+ id, name, rev = "gns://a", "a.bin", "a-r1"
+ case `{"docid":"gns://b","rev":"b-r1"}`:
+ id, name, rev = "gns://b", "b.bin", "b-r1"
+ default:
+ t.Fatalf("metadata body = %s", body)
+ }
+ _, _ = fmt.Fprintf(w, `{"docid":%q,"name":%q,"type":"file","rev":%q,"size":%d}`, id, name, rev, len(payloads[id]))
+ case "/api/efast/v1/file/osdownload":
+ assertBearer(t, r, "oauth-access")
+ body, _ := io.ReadAll(r.Body)
+ id := "gns://a"
+ object := "a"
+ if strings.Contains(string(body), `"docid":"gns://b"`) {
+ id, object = "gns://b", "b"
+ }
+ _ = id
+ _, _ = fmt.Fprintf(w, `{"authrequest":["GET",%q]}`, server.URL+"/objects/"+object+"?signature=fixture")
+ case "/objects/a":
+ assertNoAuthorization(t, r)
+ _, _ = io.WriteString(w, payloads["gns://a"])
+ case "/objects/b":
+ assertNoAuthorization(t, r)
+ if failB.Load() {
+ http.Error(w, "fixture failure", http.StatusServiceUnavailable)
+ return
+ }
+ _, _ = io.WriteString(w, payloads["gns://b"])
+ default:
+ http.Error(w, "unexpected", http.StatusNotFound)
+ }
+ })
+ server.StartTLS()
+ defer server.Close()
+
+ transport := httpx.New(server.Client().Transport, httpx.Policy{MaxAttempts: 1})
+ authenticated := anyshare.NewClient(server.URL, transport, staticToken("oauth-access"))
+ opener, err := sharelink.NewOpener(server.URL, transport, authenticated)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session, err := opener.Open(context.Background(), server.URL+"/link/"+linkID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ destination := t.TempDir()
+ progress := &progressRecorder{}
+ results, err := session.Download(context.Background(), []string{"nested/b.bin", "a.bin"}, destination, false, progress)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(results) != 2 || results[0].Entry.Path != "nested/b.bin" || results[1].Entry.Path != "a.bin" {
+ t.Fatalf("results = %#v", results)
+ }
+ for relative, want := range map[string]string{"nested/b.bin": "BBBBB", "a.bin": "AAA"} {
+ got, err := os.ReadFile(filepath.Join(destination, filepath.FromSlash(relative)))
+ if err != nil || string(got) != want {
+ t.Fatalf("read %s = %q, %v", relative, got, err)
+ }
+ }
+ if !reflect.DeepEqual(progress.started, []int64{8}) || progress.advanced != 8 || progress.finished != 1 {
+ t.Fatalf("progress = %#v", progress)
+ }
+
+ failB.Store(true)
+ partialDestination := t.TempDir()
+ partial, err := session.Download(context.Background(), []string{"a.bin", "nested/b.bin"}, partialDestination, false, nil)
+ var appErr *apperr.Error
+ if !errors.As(err, &appErr) || !strings.Contains(appErr.Message, "after 1 completed file") {
+ t.Fatalf("partial error = %v", err)
+ }
+ if len(partial) != 1 || partial[0].Entry.Path != "a.bin" {
+ t.Fatalf("partial results = %#v", partial)
+ }
+ if got, readErr := os.ReadFile(filepath.Join(partialDestination, "a.bin")); readErr != nil || string(got) != "AAA" {
+ t.Fatalf("completed file = %q, %v", got, readErr)
+ }
+ if _, statErr := os.Stat(filepath.Join(partialDestination, "nested", "b.bin")); !errors.Is(statErr, os.ErrNotExist) {
+ t.Fatalf("failed file stat = %v, want not exist", statErr)
+ }
+}
+
+type progressRecorder struct {
+ started []int64
+ advanced int64
+ finished int
+}
+
+func (p *progressRecorder) Started(total int64) { p.started = append(p.started, total) }
+func (p *progressRecorder) Advanced(delta int64) { p.advanced += delta }
+func (p *progressRecorder) Finished() { p.finished++ }
+
+func openFolderFixture(t *testing.T) (*sharelink.Session, *httptest.Server, *int) {
+ t.Helper()
+ const linkID = "AAFOLDERFIXTURE"
+ listRequests := 0
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.EscapedPath() {
+ case "/link/" + linkID:
+ setLegacyLinkCookie(w, linkID, "fixture-secret")
+ http.Redirect(w, r, "/anyshare/link/"+linkID+"?type=anonymous&item_type=folder", http.StatusFound)
+ case "/anyshare/link/" + linkID:
+ _, _ = io.WriteString(w, "landing")
+ case "/api/shared-link/v1/links/" + linkID:
+ _, _ = io.WriteString(w, `{"type":"anonymous","id":"`+linkID+`","item":{"belongs_to":"document","id":"gns://root","type":"folder"}}`)
+ case "/api/efast/v1/entry-item":
+ _, _ = io.WriteString(w, `[{"id":"gns://root","name":"Root","type":"folder","size":-1}]`)
+ case "/api/efast/v1/folders/gns:%2F%2Froot/sub_objects":
+ listRequests++
+ _, _ = io.WriteString(w, `{"dirs":[],"files":[],"next_marker":""}`)
+ default:
+ http.Error(w, "unexpected", http.StatusNotFound)
+ }
+ }))
+ session, err := newOpener(t, server, nil).Open(context.Background(), server.URL+"/link/"+linkID)
+ if err != nil {
+ server.Close()
+ t.Fatal(err)
+ }
+ return session, server, &listRequests
+}
+
+func newOpener(t *testing.T, server *httptest.Server, authenticated *anyshare.Client) *sharelink.Opener {
+ t.Helper()
+ opener, err := sharelink.NewOpener(server.URL, httpx.New(server.Client().Transport, httpx.Policy{MaxAttempts: 1}), authenticated)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return opener
+}
+
+func assertNoAuthorization(t *testing.T, r *http.Request) {
+ t.Helper()
+ if got := r.Header.Get("Authorization"); got != "" {
+ t.Fatalf("authorization = %q, want empty", got)
+ }
+}
+
+func assertBearer(t *testing.T, r *http.Request, token string) {
+ t.Helper()
+ if got := r.Header.Get("Authorization"); got != "Bearer "+token {
+ t.Fatalf("authorization = %q", got)
+ }
+ if strings.Contains(r.RequestURI, token) {
+ t.Fatalf("request URI leaked token: %s", r.RequestURI)
+ }
+}
+
+func assertCookie(t *testing.T, r *http.Request, name, value string) {
+ t.Helper()
+ want := name + "=" + value
+ found := false
+ for _, header := range r.Header.Values("Cookie") {
+ for _, pair := range strings.Split(header, ";") {
+ if strings.TrimSpace(pair) == want {
+ found = true
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("cookie %q is missing", name)
+ }
+}
+
+func assertNoCookie(t *testing.T, r *http.Request) {
+ t.Helper()
+ if got := r.Header.Get("Cookie"); got != "" {
+ t.Fatalf("cookie = %q, want empty", got)
+ }
+}
+
+func setLegacyLinkCookie(w http.ResponseWriter, linkID, value string) {
+ w.Header().Add("Set-Cookie", "link_token:"+linkID+"="+value+"; Path=/; Secure; HttpOnly")
+}
diff --git a/internal/sharelink/token_transport.go b/internal/sharelink/token_transport.go
new file mode 100644
index 0000000..a5058ef
--- /dev/null
+++ b/internal/sharelink/token_transport.go
@@ -0,0 +1,73 @@
+package sharelink
+
+import (
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+// linkTokenTransport handles AnyShare's legacy colon-bearing cookie name,
+// which net/http/cookiejar intentionally rejects as an RFC cookie name. The
+// value remains in memory and is attached only to the configured HTTPS origin.
+type linkTokenTransport struct {
+ base http.RoundTripper
+ origin *url.URL
+ cookieName string
+
+ mu sync.RWMutex
+ token string
+}
+
+func (t *linkTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ attempt := req
+ if sameOrigin(req.URL, t.origin) {
+ if token := t.Token(); token != "" {
+ attempt = req.Clone(req.Context())
+ attempt.Header = req.Header.Clone()
+ attempt.Header.Add("Cookie", t.cookieName+"="+token)
+ }
+ }
+ resp, err := t.base.RoundTrip(attempt)
+ if err != nil || resp == nil || !sameOrigin(req.URL, t.origin) {
+ return resp, err
+ }
+ for _, raw := range resp.Header.Values("Set-Cookie") {
+ if token, ok := parseLegacyLinkCookie(raw, t.cookieName); ok {
+ t.mu.Lock()
+ t.token = token
+ t.mu.Unlock()
+ }
+ }
+ return resp, nil
+}
+
+func (t *linkTokenTransport) Token() string {
+ if t == nil {
+ return ""
+ }
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ return t.token
+}
+
+func parseLegacyLinkCookie(raw, wantName string) (string, bool) {
+ pair, _, _ := strings.Cut(raw, ";")
+ name, value, ok := strings.Cut(pair, "=")
+ if !ok || strings.TrimSpace(name) != wantName {
+ return "", false
+ }
+ value = strings.TrimSpace(value)
+ if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
+ unquoted, err := strconv.Unquote(value)
+ if err != nil {
+ return "", false
+ }
+ value = unquoted
+ }
+ if value == "" || len(value) > 16<<10 || strings.ContainsAny(value, "\r\n;") {
+ return "", false
+ }
+ return value, true
+}
diff --git a/scripts/package.sh b/scripts/package.sh
index cc936c4..a7fef29 100755
--- a/scripts/package.sh
+++ b/scripts/package.sh
@@ -258,8 +258,8 @@ printf '%s\n' \
'Recommends: xdg-utils' \
'Description: lightweight command-line client for PKU NetDisk' \
' A static Linux amd64 command for per-user OAuth login, remote listing,' \
- ' directory creation, resumable uploads, downloads, and recycle-bin deletion' \
- ' on the PKU AnyShare deployment.' \
+ ' directory creation, resumable uploads, personal and shared-link downloads,' \
+ ' and recycle-bin deletion on the PKU AnyShare deployment.' \
>"$deb_stage/DEBIAN/control"
printf '%s\n' '/etc/pku-drive-cli/object-pin.json' >"$deb_stage/DEBIAN/conffiles"
(