66
77from __future__ import annotations
88
9- import asyncio
109import json
1110import logging
12- import os
1311from pathlib import Path
14- from typing import Any , Dict , Optional
12+ from typing import Any , Dict
1513
1614from codewiki .mcp .session import SessionState , SessionStore
1715
1816logger = logging .getLogger (__name__ )
1917
18+ # Max edit history entries per file (prevent unbounded memory growth)
19+ _MAX_HISTORY_PER_FILE = 20
20+
21+
22+ def _is_within (path : Path , base : Path ) -> bool :
23+ """Return True if *path* resolves to somewhere inside *base*."""
24+ try :
25+ path .resolve ().relative_to (base .resolve ())
26+ return True
27+ except ValueError :
28+ return False
29+
30+
31+ def _safe_doc_path (session : SessionState , filename : str ) -> Path | None :
32+ """Resolve *filename* within session.output_dir, guarding against traversal."""
33+ if not filename .endswith (".md" ):
34+ filename += ".md"
35+ output_base = Path (session .output_dir ).resolve ()
36+ doc_path = (output_base / filename ).resolve ()
37+ if not _is_within (doc_path , output_base ):
38+ return None
39+ return doc_path
40+
41+
42+ def _ensure_parent_dirs (path : Path ) -> None :
43+ """Create parent directories if they don't exist."""
44+ path .parent .mkdir (parents = True , exist_ok = True )
45+
2046
2147async def _validate_mermaid (file_path : str , relative_path : str ) -> str :
2248 """Run Mermaid validation and return the result string."""
@@ -27,9 +53,20 @@ async def _validate_mermaid(file_path: str, relative_path: str) -> str:
2753 return f"Mermaid validation skipped: { e } "
2854
2955
30- def _ensure_parent_dirs (path : Path ) -> None :
31- """Create parent directories if they don't exist."""
32- path .parent .mkdir (parents = True , exist_ok = True )
56+ def _save_history (session : SessionState , doc_path : Path , content : str ) -> None :
57+ """Append *content* to edit history for *doc_path*, capped at _MAX_HISTORY_PER_FILE."""
58+ history = session .registry .get ("file_history" )
59+ if history is None :
60+ history = {}
61+ elif isinstance (history , str ):
62+ history = json .loads (history )
63+ key = str (doc_path )
64+ entry = history .setdefault (key , [])
65+ entry .append (content )
66+ # Trim to last N entries
67+ if len (entry ) > _MAX_HISTORY_PER_FILE :
68+ del entry [: len (entry ) - _MAX_HISTORY_PER_FILE ]
69+ session .registry ["file_history" ] = history # keep as native dict
3370
3471
3572async def handle_write_doc_file (
@@ -43,11 +80,12 @@ async def handle_write_doc_file(
4380 return json .dumps ({"error" : f"Session { session_id } not found or expired." })
4481
4582 filename = arguments ["filename" ]
46- if not filename .endswith (".md" ):
47- filename += ".md"
83+ doc_path = _safe_doc_path (session , filename )
84+ if doc_path is None :
85+ return json .dumps ({"error" : "Filename escapes output directory." })
86+
4887 content = arguments ["content" ]
4988
50- doc_path = Path (session .output_dir ) / filename
5189 _ensure_parent_dirs (doc_path )
5290
5391 if doc_path .exists ():
@@ -81,36 +119,39 @@ async def handle_edit_doc_file(
81119 return json .dumps ({"error" : f"Session { session_id } not found or expired." })
82120
83121 filename = arguments ["filename" ]
84- if not filename .endswith (".md" ):
85- filename += ".md"
122+ doc_path = _safe_doc_path (session , filename )
123+ if doc_path is None :
124+ return json .dumps ({"error" : "Filename escapes output directory." })
86125
87- doc_path = Path (session .output_dir ) / filename
88126 command = arguments ["command" ]
89127
90128 if command == "undo" :
91129 # Undo via registry history
92- history_key = str ( doc_path )
93- history = session . registry . get ( "file_history" , "{}" )
94- file_history = json .loads (history ) if isinstance ( history , str ) else history
95- path_history = file_history .get (history_key , [])
130+ history = session . registry . get ( "file_history" , {} )
131+ if isinstance ( history , str ):
132+ history = json .loads (history )
133+ path_history = history .get (str ( doc_path ) , [])
96134 if not path_history :
97135 return json .dumps ({"error" : f"No edit history found for { filename } ." })
98136 old_content = path_history .pop ()
99- file_history [ history_key ] = path_history
100- session .registry ["file_history" ] = json . dumps ( file_history )
137+ history [ str ( doc_path ) ] = path_history
138+ session .registry ["file_history" ] = history
101139 doc_path .write_text (old_content , encoding = "utf-8" )
102- return json .dumps ({"status" : "undone" , "filename" : filename })
140+
141+ # Validate Mermaid after undo
142+ mermaid_result = await _validate_mermaid (str (doc_path ), filename )
143+ return json .dumps ({
144+ "status" : "undone" ,
145+ "filename" : filename ,
146+ "mermaid_validation" : mermaid_result ,
147+ }, ensure_ascii = False )
103148
104149 if not doc_path .exists ():
105150 return json .dumps ({"error" : f"File not found: { filename } . Use write_doc_file to create it." })
106151
107152 # Save current content to history before editing
108153 current_content = doc_path .read_text (encoding = "utf-8" )
109- history_key = str (doc_path )
110- history = session .registry .get ("file_history" , "{}" )
111- file_history = json .loads (history ) if isinstance (history , str ) else history
112- file_history .setdefault (history_key , []).append (current_content )
113- session .registry ["file_history" ] = json .dumps (file_history )
154+ _save_history (session , doc_path , current_content )
114155
115156 if command == "str_replace" :
116157 old_str = arguments .get ("old_str" )
0 commit comments