-
Notifications
You must be signed in to change notification settings - Fork 8.1k
Expand file tree
/
Copy pathlocal.py
More file actions
88 lines (70 loc) · 3.6 KB
/
Copy pathlocal.py
File metadata and controls
88 lines (70 loc) · 3.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
"""The default store: the local filesystem, sandboxed to a base directory."""
from __future__ import annotations
from contextlib import AbstractContextManager
import os
from pathlib import Path
from typing import TextIO
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
validate_file_path,
)
class LocalFileStore:
"""Reads and writes the local filesystem.
Containment is :func:`validate_file_path`: a resolved path must stay
inside ``base_dir`` (the working directory by default), with symlinks and
``..`` segments resolved first.
"""
label = "local filesystem"
def resolve(self, path: str, base_dir: str | None = None) -> str:
"""Resolve *path*, confining it to *base_dir*."""
return validate_file_path(path, base_dir)
def normalize(self, path: str, base_dir: str | None = None) -> str:
"""Resolve *path* the way the sandbox does, without rejecting it.
``validate_file_path`` and ``format_path_for_display`` both join a
relative path onto *base_dir* rather than the working directory.
Normalization has to agree with them, or the same relative string
would mean two different files.
"""
if os.path.isabs(path):
return os.path.realpath(path)
base = os.path.realpath(base_dir) if base_dir is not None else os.getcwd()
return os.path.realpath(os.path.join(base, path))
def resolve_within(self, directory: str, filename: str) -> str:
"""Join *filename* under *directory*, blocking every escape route.
``..``, absolute paths and symlinks are all resolved before the
check. ``is_relative_to`` compares whole path components, so it is
safe on case-insensitive filesystems and avoids the "//" prefix edge
case. A filename resolving to the directory itself (an empty
filename, say) is not a valid file target.
"""
root = Path(directory)
try:
resolved = Path(os.path.join(directory, filename)).resolve()
except (OSError, ValueError) as exc:
# e.g. an embedded null byte or an over-long name, which trip the
# underlying syscall. str() on an OSError carries the absolute
# filename, and the tools put this message straight into
# agent-visible output, so strip it back to the reason.
raise ValueError(format_error_for_display(exc)) from exc
if not resolved.is_relative_to(root) or resolved == root:
raise ValueError("the filename must not escape the target directory")
return str(resolved)
def display(self, resolved: str, base: str | None = None) -> str:
"""Return a path label with absolute prefixes stripped."""
return format_path_for_display(resolved, base)
def exists(self, resolved: str) -> bool:
return os.path.exists(resolved)
def ensure_parent(self, resolved: str) -> None:
"""Create the parent directory, including any missing ancestors."""
os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True)
def open_text(self, resolved: str, encoding: str) -> AbstractContextManager[TextIO]:
return open(resolved, "r", encoding=encoding)
def write_text(
self, resolved: str, content: str, encoding: str, *, overwrite: bool
) -> None:
# "x" makes the create-exclusive check atomic, so an existence race
# surfaces as FileExistsError rather than silently clobbering.
mode = "w" if overwrite else "x"
with open(resolved, mode, encoding=encoding) as handle:
handle.write(content)