359 lines
14 KiB
Python
359 lines
14 KiB
Python
"""Manages all file and folder operations inside the workspace directory.
|
|
|
|
Every method validates that the target path stays inside the workspace before
|
|
touching the filesystem, preventing path-traversal attacks.
|
|
"""
|
|
|
|
import streamlit as st
|
|
from pathlib import Path
|
|
|
|
from backend.managers.debug_logger import get_logger
|
|
logger = get_logger(__name__)
|
|
|
|
# The workspace folder is created at module load so it always exists.
|
|
WORKSPACE = Path("workspace")
|
|
WORKSPACE.mkdir(exist_ok=True)
|
|
|
|
class FileManager:
|
|
"""Manages all file and folder operations inside the workspace directory.
|
|
|
|
Every public method resolves the given path and verifies that the result
|
|
stays within ``base_path`` before touching the filesystem. This prevents
|
|
path-traversal attacks where a caller might pass ``../../etc/passwd``.
|
|
|
|
The workspace directory is created on first use if it does not yet exist.
|
|
"""
|
|
|
|
def __init__(self, base_path=Path("workspace")) -> None:
|
|
self.base_path = Path(base_path)
|
|
self.base_path.mkdir(exist_ok=True)
|
|
|
|
def create_folder(self, relative_path: str, name: str) -> bool:
|
|
"""
|
|
Creates a new folder at the specified relative path.
|
|
The relative_path should be the path to the folder relative to the base path,
|
|
and name should be the name of the new folder (without any slashes).
|
|
|
|
Args:
|
|
relative_path (str): The relative path (without base path) where the new folder should be created.
|
|
name (str): The name of the new folder to create (should not contain slashes).
|
|
Returns:
|
|
bool: True if folder was created successfully, False otherwise.
|
|
"""
|
|
logger.info("Creating folder at %s named %s", relative_path, name)
|
|
|
|
if not name:
|
|
logger.warning("Invalid folder name")
|
|
st.error(f"Invalid folder name: {name}")
|
|
return False
|
|
|
|
# Slashes in the name would silently create nested paths — reject them.
|
|
if "/" in name or "\\" in name:
|
|
logger.warning("'/' or '\\' in foldername not allowed")
|
|
st.error(f"Invalid folder name (no slashes allowed): {name}")
|
|
return False
|
|
|
|
name = Path(name)
|
|
if relative_path:
|
|
relative_path = Path(relative_path)
|
|
else:
|
|
relative_path = Path()
|
|
|
|
folder_path = (self.base_path / relative_path / name).resolve()
|
|
|
|
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
|
if not str(folder_path).startswith(str(self.base_path.resolve())):
|
|
st.error(f"Access denied: {relative_path}")
|
|
return False
|
|
|
|
try:
|
|
folder_path.mkdir(exist_ok=False)
|
|
logger.info("Folder created successfully.")
|
|
return True
|
|
except FileExistsError:
|
|
logger.warning("Folder already exists.")
|
|
st.warning(f"Folder already exists: {relative_path}")
|
|
return False
|
|
except Exception as e:
|
|
logger.exception("Error creating folder %s: %s", relative_path, str(e))
|
|
st.error(f"Error creating folder {relative_path}: {str(e)}")
|
|
return False
|
|
|
|
def create_file(self, relative_path: str, name: str) -> bool:
|
|
"""
|
|
Creates a new file at the specified relative path.
|
|
The relative_path should be the path to the folder relative to the base path where the file should be created,
|
|
and name should be the name of the new file (without any slashes).
|
|
|
|
Args:
|
|
relative_path (str): The relative path (without base path) where the new file should be created.
|
|
name (str): The name of the new file to create (should not contain slashes).
|
|
Returns:
|
|
bool: True if file was created successfully, False otherwise.
|
|
"""
|
|
logger.info("Creating file at %s named %s", relative_path, name)
|
|
|
|
if not name or name.strip() == "" :
|
|
logger.warning("Invalid folder name")
|
|
st.error(f"Invalid file name: {name}")
|
|
return False
|
|
|
|
name = Path(name)
|
|
if not name.suffix:
|
|
name = name.with_suffix(".txt") # Default to .txt if no extension provided
|
|
logger.info("No suffix was provided, creating .txt file")
|
|
|
|
if relative_path:
|
|
relative_path = Path(relative_path)
|
|
else:
|
|
relative_path = Path()
|
|
|
|
file_path = (self.base_path / relative_path / name).resolve()
|
|
|
|
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
|
if not str(file_path).startswith(str(self.base_path.resolve())):
|
|
st.error(f"Access denied: {relative_path}")
|
|
return False
|
|
|
|
try:
|
|
file_path.touch(exist_ok=False)
|
|
logger.info("File created successfully.")
|
|
return True
|
|
except FileExistsError:
|
|
logger.warning("Folder already exists.")
|
|
st.warning(f"File already exists: {relative_path}")
|
|
return False
|
|
except Exception as e:
|
|
logger.exception("Error creating file %s: %s", relative_path, str(e))
|
|
st.error(f"Error creating file {relative_path}: {str(e)}")
|
|
return False
|
|
|
|
def read_file(self, relative_path: Path) -> str:
|
|
"""
|
|
Reads the content of a file.
|
|
Accepts an absolute Path object (as stored in st.session_state.open_files).
|
|
The path is validated to ensure it stays inside the workspace.
|
|
|
|
Args:
|
|
relative_path (Path): Absolute path to the file to read.
|
|
Returns:
|
|
str: The content of the file, or an empty string if there was an error.
|
|
"""
|
|
logger.info("Reading file at %s.", relative_path)
|
|
file_path = (relative_path).resolve()
|
|
|
|
if not file_path.exists():
|
|
st.error(f"File not found: {relative_path}")
|
|
logger.warning("Filepath does not exist.")
|
|
return ""
|
|
if not file_path.is_file():
|
|
st.error(f"Path is not a file: {relative_path}")
|
|
logger.warning("Path is not a file.")
|
|
return ""
|
|
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
|
if not str(file_path).startswith(str(self.base_path.resolve())):
|
|
st.error(f"Access denied: {relative_path}")
|
|
logger.warning("Access denied. File ist outside WORKSPACE")
|
|
return ""
|
|
|
|
try:
|
|
with open(file_path, "r") as f:
|
|
content = f.read()
|
|
logger.info("File read successfully.")
|
|
return content
|
|
except FileNotFoundError:
|
|
# REVIEW: unreachable code — FileNotFoundError cannot be raised here because
|
|
# `file_path.exists()` is already checked above and returns "" on failure.
|
|
st.error(f"File not found: {relative_path}")
|
|
logger.warning("File not found")
|
|
return ""
|
|
except Exception as e:
|
|
st.error(f"Error reading file {relative_path}: {str(e)}")
|
|
logger.exception("Error reading file at %s: %s", relative_path, e)
|
|
return ""
|
|
|
|
def save_file(self, relative_path: str, content: str) -> bool:
|
|
"""
|
|
Saves content to a file.
|
|
Accepts an absolute path string (as stored in st.session_state.open_files).
|
|
The path is validated to ensure it stays inside the workspace.
|
|
|
|
Args:
|
|
relative_path (str): Absolute path to the file to save, including the file name.
|
|
content (str): The content to write to the file.
|
|
Returns:
|
|
bool: True if save was successful, False otherwise.
|
|
"""
|
|
logger.info("Saving file at %s.", relative_path)
|
|
|
|
file_path = (Path(relative_path)).resolve()
|
|
|
|
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
|
if not str(file_path).startswith(str(self.base_path.resolve())):
|
|
st.error(f"Access denied: {relative_path}")
|
|
logger.warning("Access denied. File outside WORKSPACE.")
|
|
return False
|
|
|
|
try:
|
|
with open(file_path, "w") as f:
|
|
f.write(content)
|
|
logger.info("File written successfully.")
|
|
return True
|
|
except Exception as e:
|
|
st.error(f"Error saving file {relative_path}: {str(e)}")
|
|
logger.exception("Error saving file %s: %s", relative_path, e)
|
|
return False
|
|
|
|
def rename_file(self, old_relative_path: str, new_name: str) -> bool:
|
|
"""
|
|
Renames a file while keeping the same extension.
|
|
The new_name should not include the extension, as it will be preserved from the old name.
|
|
|
|
Args:
|
|
old_relative_path (str): The current relative path (without base path) of the file to rename, including the file name.
|
|
new_name (str): The new name for the file, without extension.
|
|
Returns:
|
|
bool: True if rename was successful, False otherwise.
|
|
"""
|
|
logger.info("Rename file at %s to %s.", old_relative_path, new_name)
|
|
|
|
if not new_name or new_name.strip() == "":
|
|
st.error(f"Invalid file name: {new_name}")
|
|
logger.warning("New Name is empty.")
|
|
return False
|
|
|
|
file_type = Path(old_relative_path).suffix
|
|
new_name = Path(new_name)
|
|
|
|
# Force the original extension so the file type cannot be changed by renaming.
|
|
if not Path(new_name).suffix == file_type:
|
|
new_name = Path(new_name).with_suffix(file_type) # Ensure the file extension remains the same
|
|
|
|
old_file_path = (Path(self.base_path / old_relative_path)).resolve()
|
|
new_file_path = old_file_path.parent / new_name
|
|
|
|
# Both old and new paths must stay inside the workspace.
|
|
if not str(old_file_path).startswith(str(self.base_path.resolve())) or not str(new_file_path).startswith(str(self.base_path.resolve())):
|
|
st.error(f"Access denied: {old_relative_path}")
|
|
logger.warning("Access denied, file outside WORKSPACE.")
|
|
return False
|
|
|
|
try:
|
|
old_file_path.rename(new_file_path)
|
|
logger.info("Renamed successfully.")
|
|
return True
|
|
except FileNotFoundError:
|
|
st.error(f"File not found: {old_relative_path}")
|
|
logger.warning("Original file not found.")
|
|
return False
|
|
except Exception as e:
|
|
st.error(f"Error renaming file {old_relative_path} to {new_name}: {str(e)}")
|
|
logger.exception("Error deleting folder %s to %s: %s", old_relative_path, new_name, str(e))
|
|
return False
|
|
|
|
|
|
def delete_folder(self, relative_path: str) -> bool:
|
|
"""Deletes a folder and all its contents.
|
|
The relative_path should be the path to the folder relative to the base path.
|
|
|
|
Args:
|
|
relative_path (str): The relative path (without base path) to the folder to delete.
|
|
Returns:
|
|
bool: True if deletion was successful, False otherwise.
|
|
"""
|
|
logger.info("Deleting folder %s.", relative_path)
|
|
|
|
folder_path = (self.base_path / relative_path).resolve()
|
|
|
|
# Ensure the resolved path is still inside the workspace (prevents path traversal).
|
|
if not str(folder_path).startswith(str(self.base_path.resolve())):
|
|
st.error(f"Access denied: {relative_path}")
|
|
logger.warning("Access denied, folder outside WORKSPACE.")
|
|
return False
|
|
|
|
if not folder_path.exists():
|
|
st.error(f"Folder not found: {relative_path}")
|
|
logger.warning("Folder path not found.")
|
|
return False
|
|
|
|
try:
|
|
import shutil
|
|
shutil.rmtree(folder_path)
|
|
logger.info("Folder deleted successfully.")
|
|
return True
|
|
except Exception as e:
|
|
st.error(f"Error deleting folder {relative_path}: {str(e)}")
|
|
logger.exception("Error deleting folder %s: %s", relative_path, str(e))
|
|
return False
|
|
|
|
def delete_file(self, relative_path: str) -> bool:
|
|
"""Deletes a file.
|
|
The relative_path should be the path to the file relative to the base path.
|
|
|
|
Args:
|
|
relative_path (str): The relative path (without base path) to the file to delete, including the file name.
|
|
Returns:
|
|
bool: True if deletion was successful, False otherwise.
|
|
"""
|
|
logger.info("Deleting file %s.", relative_path)
|
|
file_path = Path(relative_path)
|
|
abs_file_path = (Path(self.base_path) / file_path).resolve()
|
|
|
|
if not str(abs_file_path).startswith(str(self.base_path.resolve())):
|
|
st.error(f"Access denied: {relative_path}")
|
|
logger.warning("Access denied, file outside WORKSPACE.")
|
|
return False
|
|
|
|
try:
|
|
abs_file_path.unlink()
|
|
logger.info("File deleted successfully.")
|
|
return True
|
|
except FileNotFoundError:
|
|
st.error(f"File not found: {relative_path}")
|
|
logger.warning("File not found")
|
|
return False
|
|
except Exception as e:
|
|
st.error(f"Error deleting file {relative_path}: {str(e)}")
|
|
logger.exception("Error deleting folder %s: %s", relative_path, str(e))
|
|
return False
|
|
|
|
def get_file_tree(self):
|
|
"""
|
|
Builds a nested dictionary representing the file tree starting from the base path.
|
|
Directories are represented as keys with dictionary values,
|
|
and files are represented as keys with None
|
|
|
|
Returns:
|
|
dict: A nested dictionary representing the file tree.
|
|
"""
|
|
logger.info("Getting file tree ...")
|
|
|
|
def build_tree(path: Path):
|
|
|
|
tree = {}
|
|
|
|
for item in sorted(path.iterdir()):
|
|
if item.is_dir():
|
|
tree[item.name] = build_tree(item) # recurse into sub-folders
|
|
else:
|
|
tree[item.name] = None # leaf node for files
|
|
return tree
|
|
return build_tree(self.base_path)
|
|
|
|
def list_files(self, extensions: list[str] | None = None) -> list[Path]:
|
|
"""Returns a flat list of all files in the workspace.
|
|
|
|
Args:
|
|
extensions: Optional list of extensions to filter by, e.g. ['.py', '.js'].
|
|
If None, all files are returned.
|
|
Returns:
|
|
List of absolute Path objects for all matching files.
|
|
"""
|
|
files = (p for p in self.base_path.rglob("*") if p.is_file())
|
|
if extensions is not None:
|
|
files = (p for p in files if p.suffix in extensions)
|
|
return sorted(files)
|
|
|
|
if __name__ == "__main__":
|
|
FileManager()
|