File size: 10,066 Bytes
71d239c
 
7603aa2
 
 
 
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7603aa2
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7603aa2
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7603aa2
71d239c
 
 
 
 
7603aa2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
"""Turn whatever the user gives us into a clean list of analysable files.

Handles a ZIP upload, a single pasted/uploaded file, a folder path, or a
GitHub URL. Filters out noise (node_modules, .git, binaries, lockfiles),
enforces size caps, and — critically — runs a **secret scan** so we never
display or send API keys and passwords to the model.
"""
from __future__ import annotations

import os
import re
import tempfile
import zipfile
from dataclasses import dataclass, field

import config


@dataclass
class SourceFile:
    path: str          # project-relative, forward slashes
    abspath: str       # where it lives on disk right now
    text: str          # file contents, with secrets already redacted
    lang: str


@dataclass
class Ingested:
    name: str
    files: list[SourceFile] = field(default_factory=list)
    skipped: list[str] = field(default_factory=list)       # listed but not parsed
    secrets_found: list[str] = field(default_factory=list)  # redacted descriptions
    note: str = ""                                          # user-facing caveat, if any


# --- Secret scanning --------------------------------------------------------
# Best-effort, conservative patterns. The goal is to protect the user, not to be
# a full secret scanner. Each match is redacted in-place before anything is shown
# or sent to the model.
_SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], None | str] = ()
SECRET_RULES: list[tuple[str, re.Pattern[str]]] = [
    ("OpenAI / Anthropic-style key", re.compile(r"\b(sk-[A-Za-z0-9_-]{16,}|sk-ant-[A-Za-z0-9_-]{16,})")),
    ("AWS access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
    ("Google API key", re.compile(r"\bAIza[0-9A-Za-z_-]{30,}\b")),
    ("GitHub token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
    ("Slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
    ("Private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
    # Generic "NAME = 'long-secret'" assignments for password/secret/token/api_key.
    ("Hard-coded secret value", re.compile(
        r"(?i)\b(password|passwd|secret|token|api[_-]?key|access[_-]?key)\b\s*[:=]\s*"
        r"['\"][^'\"]{8,}['\"]")),
]
_REDACTION = "«REDACTED-SECRET»"


def redact_secrets(text: str) -> tuple[str, list[str]]:
    """Replace likely secrets with a placeholder. Returns (clean_text, hits)."""
    hits: list[str] = []
    for label, pattern in SECRET_RULES:
        if pattern.search(text):
            hits.append(label)
            text = pattern.sub(_REDACTION, text)
    return text, hits


# --- Filtering --------------------------------------------------------------
def _lang_for(path: str) -> str | None:
    _, ext = os.path.splitext(path.lower())
    if ext in config.BINARY_EXTS:
        return None
    return config.LANG_BY_EXT.get(ext)


def _should_skip_dir(name: str) -> bool:
    return name in config.IGNORE_DIRS or name.startswith(".") or name == "__MACOSX"


def _iter_files(root: str):
    """Yield (relpath, abspath) for candidate files under root, pruning noise."""
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if not _should_skip_dir(d)]
        for fn in filenames:
            if fn.lower() in config.IGNORE_FILES:
                continue
            abspath = os.path.join(dirpath, fn)
            rel = os.path.relpath(abspath, root).replace(os.sep, "/")
            yield rel, abspath


def _read(abspath: str) -> str | None:
    try:
        if os.path.getsize(abspath) > config.MAX_FILE_BYTES:
            return None
        with open(abspath, "r", encoding="utf-8", errors="ignore") as fh:
            return fh.read()
    except (OSError, ValueError):
        return None


# --- Public entry points ----------------------------------------------------
def from_folder(root: str, name: str | None = None) -> Ingested:
    """Analyse a directory already on disk."""
    out = Ingested(name=name or os.path.basename(os.path.normpath(root)) or "your project")
    total = 0
    for rel, abspath in _iter_files(root):
        lang = _lang_for(rel)
        base = os.path.basename(rel).lower()
        is_manifest = base in config.DEP_MANIFESTS
        if lang is None and not is_manifest:
            out.skipped.append(rel)
            continue
        text = _read(abspath)
        if text is None:
            out.skipped.append(rel)
            continue
        total += len(text.encode("utf-8", "ignore"))
        if total > config.MAX_TOTAL_BYTES or len(out.files) >= config.MAX_FILES:
            out.skipped.append(rel)
            out.note = (f"Large project — analysed the first {len(out.files)} files; "
                        f"the rest are listed but not narrated.")
            continue
        clean, hits = redact_secrets(text)
        for h in hits:
            desc = f"{rel}: {h}"
            if desc not in out.secrets_found:
                out.secrets_found.append(desc)
        out.files.append(SourceFile(path=rel, abspath=abspath, text=clean,
                                    lang=lang or "other"))
    out.files.sort(key=lambda f: (0 if f.lang in ("python", "javascript", "typescript") else 1, f.path))
    out.skipped.sort()
    return out


def from_zip(zip_path: str, name: str | None = None) -> Ingested:
    """Unzip to a temp dir and analyse it."""
    tmp = tempfile.mkdtemp(prefix="storycode_")
    derived = name or os.path.splitext(os.path.basename(zip_path))[0]
    with zipfile.ZipFile(zip_path) as zf:
        for member in zf.namelist():
            # Guard against zip-slip; skip absolute / parent-escaping paths.
            target = os.path.normpath(os.path.join(tmp, member))
            if not target.startswith(os.path.abspath(tmp) + os.sep) and target != os.path.abspath(tmp):
                continue
            if member.endswith("/"):
                continue
            os.makedirs(os.path.dirname(target), exist_ok=True)
            try:
                with zf.open(member) as src, open(target, "wb") as dst:
                    dst.write(src.read())
            except (OSError, zipfile.BadZipFile):
                continue
    # A zip often wraps everything in a single top folder; descend into it.
    entries = [e for e in os.listdir(tmp) if not e.startswith("__MACOSX")]
    root = tmp
    if len(entries) == 1 and os.path.isdir(os.path.join(tmp, entries[0])):
        root = os.path.join(tmp, entries[0])
        derived = name or entries[0]
    return from_folder(root, name=derived)


def from_text(text: str, filename: str = "snippet.py") -> Ingested:
    """Analyse a single pasted file."""
    lang = _lang_for(filename) or "other"
    clean, hits = redact_secrets(text)
    out = Ingested(name=filename)
    out.files.append(SourceFile(path=filename, abspath="", text=clean, lang=lang))
    out.secrets_found = [f"{filename}: {h}" for h in hits]
    return out


# --- GitHub URL ingestion ---------------------------------------------------
_GH_URL_RE = re.compile(
    r"https?://github\.com/(?P<owner>[^/\s]+)/(?P<repo>[^/\s]+?)(?:\.git)?"
    r"(?:/(?:tree|blob)/(?P<branch>[^/\s]+))?"
    r"(?:/(?P<path>[^\s]*))?"
    r"\s*$"
)


def parse_github_url(url: str) -> tuple[str, str, str] | None:
    """Parse a GitHub URL into (owner, repo, branch). Returns None if invalid."""
    m = _GH_URL_RE.search(url.strip())
    if not m:
        return None
    owner = m.group("owner")
    repo = m.group("repo")
    branch = m.group("branch") or "HEAD"
    return owner, repo, branch


def from_github(url: str, name: str | None = None) -> Ingested:
    """Download a public GitHub repo and analyse it.

    Uses the GitHub archive endpoint (no git required). For large repos,
    only the first MAX_FILES files are analysed; the rest are listed but
    not narrated (handled by from_folder's size caps).
    """
    import urllib.request

    parsed = parse_github_url(url)
    if not parsed:
        return Ingested(name="invalid URL", note="Could not parse GitHub URL. Use: https://github.com/owner/repo")

    owner, repo, branch = parsed
    repo_name = name or f"{owner}/{repo}"

    # Try the archive endpoint: main → master fallback
    archive_urls = [
        f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip",
        f"https://github.com/{owner}/{repo}/archive/refs/heads/main.zip",
        f"https://github.com/{owner}/{repo}/archive/refs/heads/master.zip",
    ]

    tmp = tempfile.mkdtemp(prefix="storycode_gh_")
    zip_path = os.path.join(tmp, "repo.zip")

    for archive_url in archive_urls:
        try:
            req = urllib.request.Request(archive_url, headers={"User-Agent": "StoryCode/1.0"})
            with urllib.request.urlopen(req, timeout=60) as resp:
                data = resp.read()
            with open(zip_path, "wb") as f:
                f.write(data)
            break
        except (urllib.error.URLError, OSError):
            continue
    else:
        return Ingested(
            name=repo_name,
            note="Could not download the repository. Make sure it's public and the URL is correct.",
        )

    # Extract and find the root
    with zipfile.ZipFile(zip_path) as zf:
        for member in zf.namelist():
            target = os.path.normpath(os.path.join(tmp, member))
            if not target.startswith(os.path.abspath(tmp) + os.sep) and target != os.path.abspath(tmp):
                continue
            if member.endswith("/"):
                continue
            os.makedirs(os.path.dirname(target), exist_ok=True)
            try:
                with zf.open(member) as src, open(target, "wb") as dst:
                    dst.write(src.read())
            except (OSError, zipfile.BadZipFile):
                continue

    # GitHub archives wrap in {repo}-{branch}/ folder
    entries = [e for e in os.listdir(tmp) if not e.startswith("__MACOSX") and e != "repo.zip"]
    root = tmp
    if len(entries) == 1 and os.path.isdir(os.path.join(tmp, entries[0])):
        root = os.path.join(tmp, entries[0])

    return from_folder(root, name=repo_name)