Initial public release
Build Docker Image / build (push) Failing after 1m22s

MCP (Model Context Protocol) server for reading and writing an Obsidian
vault, gated by OAuth-issued JWT bearer tokens. See README.md for setup.
This commit is contained in:
2026-05-17 23:53:00 +08:00
commit 515763bc72
31 changed files with 1931 additions and 0 deletions
@@ -0,0 +1,124 @@
using Microsoft.Extensions.Options;
using ObsidianMcp.Config;
using ObsidianMcp.Services;
namespace ObsidianMcp.Tests;
/// <summary>
/// VaultPathResolver 路径安全单测。
/// 核心场景:路径穿越、黑名单、绝对路径拒绝。
/// </summary>
public class VaultPathResolverTests : IDisposable
{
private readonly string _tempRoot;
private readonly VaultPathResolver _resolver;
public VaultPathResolverTests()
{
// 每个测试类实例创建独立临时目录作 vault root
_tempRoot = Path.Combine(Path.GetTempPath(), "obsidian-mcp-test-" + Guid.NewGuid());
Directory.CreateDirectory(_tempRoot);
// 在 tempRoot 下建一些测试文件/目录
Directory.CreateDirectory(Path.Combine(_tempRoot, "Notes"));
File.WriteAllText(Path.Combine(_tempRoot, "Notes", "test.md"), "hello");
Directory.CreateDirectory(Path.Combine(_tempRoot, "Projects"));
var opts = Options.Create(new VaultOptions
{
Root = _tempRoot,
Blacklist = ["custom-black"],
WriteWhitelist = [],
});
_resolver = new VaultPathResolver(opts);
}
// ─── 正常路径 ────────────────────────────────────────────────────────────
[Fact]
public void Resolve_ValidRelativePath_ReturnsAbsolutePath()
{
var result = _resolver.Resolve("Notes/test.md");
Assert.Equal(Path.Combine(_tempRoot, "Notes", "test.md"), result);
}
[Fact]
public void Resolve_NestedPath_ReturnsCorrectAbsolutePath()
{
var result = _resolver.Resolve("Projects");
Assert.Equal(Path.Combine(_tempRoot, "Projects"), result);
}
// ─── 路径穿越(Path Traversal) ──────────────────────────────────────────
[Theory]
[InlineData("../etc/passwd")]
[InlineData("../../Windows/System32")]
[InlineData("Notes/../../../etc/shadow")]
[InlineData("Notes/../../outside")]
public void Resolve_PathTraversal_ThrowsUnauthorized(string path)
{
var ex = Assert.Throws<UnauthorizedAccessException>(() => _resolver.Resolve(path));
Assert.Contains("路径穿越", ex.Message);
}
// ─── 绝对路径拒绝 ────────────────────────────────────────────────────────
[Theory]
[InlineData("/etc/passwd")]
[InlineData("C:\\Windows\\System32")]
public void Resolve_AbsolutePath_ThrowsUnauthorized(string path)
{
// 绝对路径判断:Path.IsPathRooted 在 Windows 上对 / 开头也返回 true
var ex = Assert.Throws<UnauthorizedAccessException>(() => _resolver.Resolve(path));
Assert.Contains("绝对路径", ex.Message);
}
// ─── Hardcode 黑名单(.obsidian / .trash / .git) ────────────────────────
[Theory]
[InlineData(".obsidian/config")]
[InlineData(".trash/deleted.md")]
[InlineData(".git/config")]
public void Resolve_HardcodeBlacklist_ThrowsUnauthorized(string path)
{
// 先确保这些目录/文件在 vault root 下存在(避免路径规范化误报)
var abs = Path.Combine(_tempRoot, path.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(abs)!);
File.WriteAllText(abs, "test");
var ex = Assert.Throws<UnauthorizedAccessException>(() => _resolver.Resolve(path));
Assert.Contains("黑名单", ex.Message);
}
// ─── Env 扩展黑名单 ──────────────────────────────────────────────────────
[Fact]
public void Resolve_CustomBlacklist_ThrowsUnauthorized()
{
var customDir = Path.Combine(_tempRoot, "custom-black");
Directory.CreateDirectory(customDir);
File.WriteAllText(Path.Combine(customDir, "test.md"), "test");
var ex = Assert.Throws<UnauthorizedAccessException>(
() => _resolver.Resolve("custom-black/test.md"));
Assert.Contains("黑名单", ex.Message);
}
// ─── 空路径 ──────────────────────────────────────────────────────────────
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Resolve_EmptyOrWhitespace_ThrowsArgumentException(string path)
{
Assert.Throws<ArgumentException>(() => _resolver.Resolve(path));
}
public void Dispose()
{
// 清理临时目录
try { Directory.Delete(_tempRoot, recursive: true); }
catch { /* best effort */ }
}
}
+139
View File
@@ -0,0 +1,139 @@
using Microsoft.Extensions.Options;
using ObsidianMcp.Config;
using ObsidianMcp.Services;
namespace ObsidianMcp.Tests;
/// <summary>
/// VaultWriteGuard 单测。
/// 核心场景:保护文件名永禁写、白名单允许、非白名单拒绝、空白名单全拒、前缀 vs 精确匹配。
/// </summary>
public class VaultWriteGuardTests : IDisposable
{
private readonly string _tempRoot;
public VaultWriteGuardTests()
{
_tempRoot = Path.Combine(Path.GetTempPath(), "obsidian-mcp-guard-" + Guid.NewGuid());
Directory.CreateDirectory(_tempRoot);
}
private VaultWriteGuard MakeGuard(string[] whitelist)
{
var opts = Options.Create(new VaultOptions
{
Root = _tempRoot,
Blacklist = [],
WriteWhitelist = whitelist,
});
var resolver = new VaultPathResolver(opts);
return new VaultWriteGuard(resolver, opts);
}
private void EnsureDirFor(string relPath)
{
var abs = Path.Combine(_tempRoot, relPath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(abs)!);
}
// ─── 白名单:前缀匹配 ────────────────────────────────────────────────────
[Theory]
[InlineData("Notes/foo.md")]
[InlineData("Notes/sub/bar.md")]
public void EnsureWritable_PrefixWhitelist_AllowsMatchingPaths(string path)
{
var guard = MakeGuard(["Notes/"]);
EnsureDirFor(path);
var result = guard.EnsureWritable(path);
Assert.True(result.StartsWith(_tempRoot, StringComparison.OrdinalIgnoreCase));
}
// ─── 白名单:精确匹配 ────────────────────────────────────────────────────
[Fact]
public void EnsureWritable_ExactWhitelist_AllowsOnlyExactPath()
{
var guard = MakeGuard(["todo.md"]);
EnsureDirFor("todo.md");
var result = guard.EnsureWritable("todo.md");
Assert.True(result.StartsWith(_tempRoot, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void EnsureWritable_ExactWhitelist_RejectsSiblingPath()
{
var guard = MakeGuard(["todo.md"]);
EnsureDirFor("other.md");
var ex = Assert.Throws<UnauthorizedAccessException>(
() => guard.EnsureWritable("other.md"));
Assert.Contains("白名单", ex.Message);
}
// ─── 保护文件名永禁(即便父目录被白名单允许) ──────────────────────────
[Theory]
[InlineData("AGENTS.md")]
[InlineData("README.md")]
[InlineData("CLAUDE.md")]
[InlineData("Notes/AGENTS.md")]
[InlineData("Notes/README.md")]
[InlineData("Notes/CLAUDE.md")]
public void EnsureWritable_ProtectedFileName_ThrowsUnauthorized(string path)
{
var guard = MakeGuard(["Notes/", "AGENTS.md", "README.md", "CLAUDE.md"]);
EnsureDirFor(path);
var ex = Assert.Throws<UnauthorizedAccessException>(() => guard.EnsureWritable(path));
Assert.Contains("禁止写入", ex.Message);
}
// ─── 空白名单:拒绝一切写入 ──────────────────────────────────────────────
[Theory]
[InlineData("Notes/foo.md")]
[InlineData("foo.md")]
public void EnsureWritable_EmptyWhitelist_RejectsEverything(string path)
{
var guard = MakeGuard([]);
EnsureDirFor(path);
var ex = Assert.Throws<UnauthorizedAccessException>(() => guard.EnsureWritable(path));
Assert.Contains("白名单", ex.Message);
}
// ─── 非白名单路径拒绝 ────────────────────────────────────────────────────
[Theory]
[InlineData("Other/note.md")]
[InlineData("random.md")]
public void EnsureWritable_NonWhitelistedPath_ThrowsUnauthorized(string path)
{
var guard = MakeGuard(["Notes/"]);
EnsureDirFor(path);
var ex = Assert.Throws<UnauthorizedAccessException>(() => guard.EnsureWritable(path));
Assert.Contains("白名单", ex.Message);
}
// ─── 反斜杠归一化 ────────────────────────────────────────────────────────
[Fact]
public void EnsureWritable_WhitelistAcceptsBackslashEntries()
{
var guard = MakeGuard(["Notes\\"]);
EnsureDirFor("Notes/foo.md");
var result = guard.EnsureWritable("Notes/foo.md");
Assert.True(result.StartsWith(_tempRoot, StringComparison.OrdinalIgnoreCase));
}
public void Dispose()
{
try { Directory.Delete(_tempRoot, recursive: true); }
catch { /* best effort */ }
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>obsidian_mcp.Tests</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\obsidian-mcp.csproj" />
</ItemGroup>
</Project>