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
11 changes: 10 additions & 1 deletion marimo/_server/api/endpoints/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,23 @@ async def index(request: Request) -> HTMLResponse:
app_manager = app_state.session_manager.app_manager(file_key)
app_config = app_manager.app.config

# Make filename relative to file router's directory if possible
filename = app_manager.filename
directory = app_state.session_manager.file_router.directory
if filename and directory:
try:
filename = str(Path(filename).relative_to(directory))
except ValueError:
pass # Keep absolute if not under directory

html = notebook_page_template(
html=html,
base_url=app_state.base_url,
user_config=config_manager.get_user_config(),
config_overrides=config_manager.get_config_overrides(),
server_token=app_state.skew_protection_token,
app_config=app_config,
filename=app_manager.filename,
filename=filename,
mode=app_state.mode,
runtime_config=[{"url": app_state.remote_url}]
if app_state.remote_url
Expand Down
5 changes: 4 additions & 1 deletion marimo/_server/api/endpoints/file_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ async def list_files(
schema:
$ref: "#/components/schemas/FileListResponse"
"""
app_state = AppState(request)
body = await parse_request(request, cls=FileListRequest)
root = body.path or file_system.get_root()
# Use file router's directory as default, fall back to cwd
directory = app_state.session_manager.file_router.directory
root = body.path or directory or file_system.get_root()
files = file_system.list_files(root)
return FileListResponse(files=files, root=root)

Expand Down
23 changes: 22 additions & 1 deletion marimo/_server/api/endpoints/files.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

from starlette.authentication import requires
Expand Down Expand Up @@ -109,9 +110,15 @@ async def rename_file(
"""
body = await parse_request(request, cls=RenameNotebookRequest)
app_state = AppState(request)

# Resolve relative filenames against the file router's directory
if not Path(body.filename).is_absolute():
directory = app_state.session_manager.file_router.directory
if directory:
body.filename = str(Path(directory) / body.filename)

filename = await abspath(body.filename)

# Convert to absolute path
app_state.require_current_session().put_control_request(
RenameNotebookCommand(filename=filename),
from_consumer_id=ConsumerId(app_state.require_current_session_id()),
Expand Down Expand Up @@ -152,6 +159,13 @@ async def save(
"""
app_state = AppState(request)
body = await parse_request(request, cls=SaveNotebookRequest)

# Resolve relative filenames against the file router's directory
if body.filename and not Path(body.filename).is_absolute():
directory = app_state.session_manager.file_router.directory
if directory:
body.filename = str(Path(directory) / body.filename)

session = app_state.require_current_session()
contents = session.app_file_manager.save(body)

Expand Down Expand Up @@ -186,6 +200,13 @@ async def copy(
"""
app_state = AppState(request)
body = await parse_request(request, cls=CopyNotebookRequest)

# Resolve relative filenames against the file router's directory
if body.destination and not Path(body.destination).is_absolute():
directory = app_state.session_manager.file_router.directory
if directory:
body.destination = str(Path(directory) / body.destination)

session = app_state.require_current_session()
contents = session.app_file_manager.copy(body)

Expand Down
8 changes: 7 additions & 1 deletion marimo/_server/api/endpoints/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import asyncio
import os
import pathlib
import tempfile
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -58,7 +59,12 @@ async def read_code(
$ref: "#/components/schemas/RecentFilesResponse"
"""
app_state = AppState(request)
files = app_state.session_manager.recents.get_recents()
# Pass the file router's directory to filter and relativize paths
directory = None
dir_str = app_state.session_manager.file_router.directory
if dir_str:
directory = pathlib.Path(dir_str)
files = app_state.session_manager.recents.get_recents(directory)
return RecentFilesResponse(files=files)


Expand Down
24 changes: 12 additions & 12 deletions marimo/_server/file_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,15 +167,16 @@ def maybe_get_single_file(self) -> Optional[MarimoFile]:

class LazyListOfFilesAppFileRouter(AppFileRouter):
def __init__(self, directory: str, include_markdown: bool) -> None:
# pass through Path to canonicalize, strips trailing slashes
self._directory = str(Path(directory))
# Make directory absolute but don't resolve symlinks to preserve user paths
abs_directory = Path(directory).absolute()
self._directory = str(abs_directory)
self.include_markdown = include_markdown
self._lazy_files: Optional[list[FileInfo]] = None

# Use PathValidator for security validation
self._validator = PathValidator(Path(directory))
# Use DirectoryScanner for file discovery
self._scanner = DirectoryScanner(directory, include_markdown)
self._validator = PathValidator(abs_directory)
# Use DirectoryScanner for file discovery (use absolute path)
self._scanner = DirectoryScanner(str(abs_directory), include_markdown)

@property
def directory(self) -> str:
Expand Down Expand Up @@ -233,23 +234,22 @@ def get_file_manager(
directory = Path(self._directory)
filepath = Path(key)

# Resolve filepath for use
# If filepath is relative, resolve it relative to directory
if not filepath.is_absolute():
filepath = directory / filepath

# Use PathValidator for security validation
# Check if file is in an allowed temp directory (e.g., for tutorials)
# If so, skip the directory validation
is_in_allowed_temp_dir = self._validator.is_file_in_allowed_temp_dir(
key
str(filepath)
)

if not is_in_allowed_temp_dir:
# Validate that filepath is inside directory
self._validator.validate_inside_directory(directory, filepath)

# Resolve filepath for use
# If directory is absolute and filepath is relative, resolve relative to directory
if directory.is_absolute() and not filepath.is_absolute():
filepath = directory / filepath
# Note: We don't call resolve() here to preserve the original path format

if filepath.exists():
return AppFileManager(str(filepath), defaults=defaults)

Expand Down
17 changes: 13 additions & 4 deletions marimo/_server/files/directory_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import os
import time
from pathlib import Path
from typing import Optional

from marimo import _loggers
Expand Down Expand Up @@ -183,10 +184,14 @@ def recurse(
continue
children = recurse(entry.path, depth + 1)
if children:
entry_path = Path(entry.path)
relative_path = str(
entry_path.relative_to(self.directory)
)
folders.append(
FileInfo(
id=entry.path,
path=entry.path,
id=relative_path,
path=relative_path,
name=entry.name,
is_directory=True,
is_marimo_file=False,
Expand All @@ -196,9 +201,13 @@ def recurse(
elif entry.name.endswith(self.allowed_extensions):
if is_marimo_app(entry.path):
file_count[0] += 1
entry_path = Path(entry.path)
relative_path = str(
entry_path.relative_to(self.directory)
)
file_info = FileInfo(
id=entry.path,
path=entry.path,
id=relative_path,
path=relative_path,
name=entry.name,
is_directory=False,
is_marimo_file=True,
Expand Down
20 changes: 11 additions & 9 deletions marimo/_server/recents.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

import os
import pathlib
from dataclasses import dataclass, field

from marimo import _loggers
from marimo._server.models.home import MarimoFile
from marimo._utils.config.config import ConfigReader
from marimo._utils.paths import pretty_path


@dataclass
Expand Down Expand Up @@ -77,7 +75,9 @@ def rename(self, old_filename: str, new_filename: str) -> None:

self.config.write_toml(state)

def get_recents(self) -> list[MarimoFile]:
def get_recents(
self, directory: pathlib.Path | None = None
) -> list[MarimoFile]:
if not self.config:
return []

Expand All @@ -86,19 +86,21 @@ def get_recents(self) -> list[MarimoFile]:
)
files: list[MarimoFile] = []

cwd = pathlib.Path.cwd()
base_dir = directory or pathlib.Path.cwd()
limited_files = state.files[: self.MAX_FILES]
for file in limited_files:
file_path = pathlib.Path(file)
Comment on lines +89 to 92
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code assumes all stored file paths in recent files are absolute, but this assumption is not enforced. If a relative path is somehow stored (e.g., through manual config file editing or a future code change), the is_relative_to check on line 93 will incorrectly return False when comparing a relative file_path to an absolute base_dir, causing valid recent files to be filtered out. Consider converting file_path to absolute before the is_relative_to check to make this code more robust.

Suggested change
base_dir = directory or pathlib.Path.cwd()
limited_files = state.files[: self.MAX_FILES]
for file in limited_files:
file_path = pathlib.Path(file)
base_dir = (directory or pathlib.Path.cwd()).resolve()
limited_files = state.files[: self.MAX_FILES]
for file in limited_files:
file_path = pathlib.Path(file)
# Normalize to an absolute path relative to base_dir, if needed
if not file_path.is_absolute():
file_path = (base_dir / file_path).resolve()
else:
file_path = file_path.resolve()

Copilot uses AI. Check for mistakes.
if _is_tmp_file(file) or cwd not in file_path.parents:
if _is_tmp_file(file) or not file_path.is_relative_to(base_dir):
continue
if not os.path.exists(file):
if not file_path.exists():
continue
# Return path relative to base_dir
relative_path = file_path.relative_to(base_dir)
files.append(
MarimoFile(
name=os.path.basename(file),
path=pretty_path(file),
last_modified=os.path.getmtime(file),
name=file_path.name,
path=str(relative_path),
last_modified=file_path.stat().st_mtime,
)
)

Expand Down
5 changes: 2 additions & 3 deletions marimo/_server/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def _get_code() -> str:
# Add recents tracking listener
self.recents = RecentFilesManager()
self._event_bus = SessionEventBus()
self._event_bus.subscribe(RecentsTrackerListener(self.recents))

# Initialize file watching components
self._watcher_manager = FileWatcherManager()
Expand Down Expand Up @@ -160,9 +161,7 @@ def create_session(
# Create the session
from marimo._runtime.commands import AppMetadata

extensions: list[SessionExtension] = [
RecentsTrackerListener(self.recents)
]
extensions: list[SessionExtension] = []
if self.watch:
extensions.append(
SessionFileWatcherExtension(
Expand Down
Loading
Loading