Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/fuse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: FUSE mount
on:
pull_request:
push:
branches: [main, work/fuse-mount-20261001]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: fuse-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true
jobs:
kernel:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: msgtest
POSTGRES_PASSWORD: msgtest
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U msgtest -d postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.15'
allow-prereleases: true
- name: Install native FUSE and optional package
id: install
run: |
sudo apt-get update -qq
sudo env NEEDRESTART_MODE=l apt-get install -y -qq fuse3 age libpcsclite-dev swig
python -m pip install uv==0.12.20
uv sync --locked --extra dev --extra fuse --python "$(command -v python)"
test -c /dev/fuse
- name: Check FUSE implementation quality
run: |
.venv/bin/ruff check src/msg/client_mount.py tests/test_client_mount.py tests/test_mount_lifecycle.py tests/test_mount_service.py scripts/check_fuse_mount.py
.venv/bin/ruff format --check src/msg/client_mount.py tests/test_client_mount.py tests/test_mount_lifecycle.py tests/test_mount_service.py scripts/check_fuse_mount.py
- name: Verify real kernel mount, byte reads and unmount
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: timeout --signal=TERM --kill-after=5s 90s .venv/bin/python scripts/check_fuse_mount.py
- name: Verify client contracts and real service authorization
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: .venv/bin/python -m pytest tests/test_client_mount.py tests/test_mount_lifecycle.py tests/test_mount_service.py --junitxml=artifacts/fuse.xml
- uses: actions/upload-artifact@v7.0.1
if: always()
with:
name: fuse-acceptance
path: artifacts/fuse.xml
81 changes: 81 additions & 0 deletions docs/fuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 把 msg 挂载为本地文件夹

`msg mount` 把服务端资源树映射为只读目录,不抓取网页,也不整站下载。
挂载后可以使用 `ls`、`cat`、`grep`、文件管理器和本地编辑器查看内容。
本版本不支持在挂载点新建、保存、移动或删除远端资源;写入仍使用明确的 CLI 操作。

## 安装与启动

Linux 安装系统 FUSE 3,例如 Debian/Ubuntu:

```sh
sudo apt-get install fuse3
```

macOS 需要先安装 macFUSE。Linux 有真实内核挂载 CI;macOS 尚未完成实机验收。
Windows 原生挂载不在本版支持范围;容器、WSL、受限沙箱必须自行具备 FUSE 设备与挂载权限。
客户端不会替用户提权或修改系统配置。

使用包含本功能的源码版本安装可选依赖(PyPI 发布前不能假定现有发行版已包含):

```sh
uv tool install --from '.[fuse]' msgctl
```

客户端和服务端都需要更新:`file.stat` 必须提供资源类型的 `container` 字段。
旧服务端会报 `mount_server_upgrade_required`,不会猜测目录或把错误当成空目录。

```sh
# 明确指定站点;采用该站点在 CLI 中已选定的账号。
msg --server https://msg.lmm.best mount ~/msg

# 已经设置默认站点后,可直接挂载。
msg mount ~/msg

# 只挂载一个远程目录。
msg mount ~/msg-main --path /main

# 选择已有账号;不创建新的挂载身份。
msg --account alice mount ~/msg-alice
```

命令在前台运行。另一终端可执行 `ls ~/msg/main`,并对列出的实际文件名使用 `cat`。
不要根据编号猜测帖子路径;挂载使用服务端返回的规范路径。
挂载点不存在时会创建一个仅当前用户可访问的目录;已存在时必须为空、属于当前用户且不可被其他用户写入。

正常退出使用 `Ctrl-C`。也可以在另一终端卸载:

```sh
fusermount3 -u ~/msg # Linux
umount ~/msg # macOS
```

## 路径与内容

服务端声明为容器的资源是目录,具有内容修订的非容器资源以原始字节呈现:
Markdown 保留原文,二进制文件不转成 Base64。证书等没有内容修订的叶节点提供鉴权后的 JSON 投影。
文件打开时固定修订,顺序读取和 seek 不会拼接两个版本;关闭后重新打开才会看到新修订。

这是 **msg 资源命名空间**,不是服务器磁盘、任意网页或数据库的镜像。
Git 仓库、网页托管资源不会自动解包为 Git 工作树或发布目录;HTTP 虚拟页面和操作入口也不伪装成本地文件。
当前仅支持 `--transport http`,其他 CLI 传输模式会明确拒绝。

## 权限、缓存和限制

挂载复用现有账号、私钥/令牌、OAuth 登录态、证书以及显式身份代理配置。
服务端仍决定每次请求能读取什么;本地挂载不会获得额外权限。
`stat`、目录分页和每次内容块读取均经过鉴权。原始文件读取关闭内核内容缓存和属性缓存,
权限被撤销后后续读取失败;已交付给本地程序的字节当然无法收回。

挂载只对当前系统用户开放,不提供 `allow_other`,并固定 `ro,nodev,nosuid,noexec`。
没有持久化内容缓存,没有自动 ACK、关注、点赞、工具执行或交易。
少量无修订 JSON 在已打开的文件句柄中保留快照,但每次读取仍重新鉴权。

内容按需读取,每次最多 64 KiB,同时最多打开 64 个文件。
每个目录最多 10,000 个条目;超过时返回 `EOVERFLOW`,不会静默截断。
可用 `--max-entries` 调整到最多 100,000。每个文件系统请求默认最多 45 秒,
可用 `--timeout` 设置为大于 0、最多 300 秒。断网和服务端错误会转成明确的文件系统错误,
不会用旧内容或空文件掩盖失败。

递归 `grep` 或文件管理器索引仍可能产生很多网络请求。建议挂载需要的子目录,
不要把整个站点交给自动备份、全文索引或杀毒扫描程序。
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ msg = "msg.cli:main"
msgd = "msg.daemon:main"

[project.optional-dependencies]
fuse = ["mfusepy>=3.1.1,<4"]
server = [
"starlette>=0.50,<2",
"uvicorn>=0.38,<1",
Expand Down
118 changes: 118 additions & 0 deletions scripts/check_fuse_mount.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Exercise the real FUSE kernel boundary; missing native support is a CI failure.

Only this smoke test supplies an in-memory backend. The production FUSE adapter,
mount flags, thread bridge, offset reads and unmount lifecycle remain unchanged.
"""

import asyncio
import errno
import os
import shutil
import subprocess
import tempfile
from pathlib import Path

from msg.client_mount import Node, ReadOnlyMount, io_error, serve_mount

BODY = bytes(range(256)) * 1200


class FixtureBackend:
async def node(self, path):
if path not in {'/', '/data.bin'}:
raise io_error(errno.ENOENT)
return Node({
'id': 'root' if path == '/' else 'file',
'path': path,
'container': path == '/',
'revision': 'v_one',
'size': len(BODY),
})

async def directory(self, path):
if path != '/':
raise io_error(errno.ENOTDIR)
return ['.', '..', 'data.bin']

async def query(self, operation, arguments):
return {}

async def read(self, node, size, offset):
return BODY[offset : offset + size]


def inspect_mount(path):
assert sorted(p.name for p in path.iterdir()) == ['data.bin']
target = path / 'data.bin'
assert target.stat().st_size == len(BODY)
with target.open('rb') as stream:
assert stream.read() == BODY
stream.seek(12345)
assert stream.read(2345) == BODY[12345:14690]
for operation in (
lambda: target.write_bytes(b'no'),
target.unlink,
lambda: (path / 'new').mkdir(),
):
try:
operation()
except OSError as exc:
assert exc.errno == errno.EROFS, exc
else:
raise AssertionError('read-only mount accepted a mutation')
assert target.read_bytes() == BODY


async def exercise(path, *, cancel=False):
import mfusepy

operations = ReadOnlyMount(FixtureBackend(), asyncio.get_running_loop())
task = asyncio.create_task(serve_mount(mfusepy, operations, path))
try:
for _ in range(250):
if task.done():
await task
raise AssertionError('FUSE exited before mounting')
if await asyncio.to_thread(os.path.ismount, path):
break
await asyncio.sleep(0.02)
else:
raise AssertionError('FUSE mount did not appear')
await asyncio.wait_for(asyncio.to_thread(inspect_mount, path), 15)
if cancel:
task.cancel()
try:
await asyncio.wait_for(task, 10)
except asyncio.CancelledError:
pass
else:
executable = shutil.which('fusermount3') or shutil.which('fusermount')
if not executable:
raise AssertionError('fusermount is required by the Linux smoke test')
await asyncio.to_thread(
subprocess.run, [executable, '-u', '--', str(path)], check=True, timeout=10
)
await asyncio.wait_for(task, 10)
assert not await asyncio.to_thread(os.path.ismount, path)
assert not operations.handles
finally:
if not task.done():
task.cancel()
try:
await asyncio.wait_for(task, 10)
except asyncio.CancelledError:
pass


async def main():
if not Path('/dev/fuse').exists():
raise SystemExit('FUSE smoke requires /dev/fuse; this is not a skipped acceptance test.')
with tempfile.TemporaryDirectory(prefix='msg-fuse-') as directory:
path = Path(directory)
await exercise(path)
await exercise(path, cancel=True)
print('FUSE kernel smoke passed: list/stat/read/seek/read-only/unmount/cancellation')


if __name__ == '__main__':
asyncio.run(main())
7 changes: 7 additions & 0 deletions src/msg/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,9 @@ def parser():
from msg.client_yubikey import add_commands as add_yubikey_commands

add_yubikey_commands(commands)
from msg.client_mount import add_commands as add_mount_commands

add_mount_commands(commands)
return cli


Expand Down Expand Up @@ -581,6 +584,10 @@ async def run(args):
require(args.handle == args.user, 'connection_user_mismatch')
elif command != 'login':
await client.require_username(args.user)
if command == 'mount':
from msg.client_mount import run_mount

return await run_mount(client, args)
if command in {'login', 'logout', 'auth'}:
from msg.client_oauth import run_command

Expand Down
Loading
Loading