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" (