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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ A language-specific parser walks the directory, parses each file into a tree-sit
| Haskell | `.hs` | functions, data types, type classes, instances |
| Erlang | `.erl` | functions, records, behaviours, modules |
| Miden Assembly | `.masm` | procedures, entrypoints, constants, invocations |
| Swift | `.swift` | functions, classes, structs, enums, protocols, extensions |
| Objective-C | `.m`, `.mm`, `.h` | C functions, classes, methods (selector-based naming) |

```mermaid
flowchart TD
Expand Down Expand Up @@ -289,6 +291,8 @@ Framework coverage:
| Miden Assembly | `export.<name>` directives |
| Haskell | top-level `main ::` / `main =` |
| Erlang | functions listed in `-export([...])` |
| Swift | `@main` app attribute |
| Objective-C | `UIApplicationDelegate` lifecycle selectors (e.g. `application:openURL:options:`) |

For anything the heuristics miss, declare entrypoints explicitly in `.trailmark/entrypoints.toml` at the project root:

Expand Down
70 changes: 70 additions & 0 deletions src/trailmark/analysis/entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,25 @@
# Haskell — `main :: IO ()` / `main = ...` at column 0
_HASKELL_MAIN = re.compile(r"^main\s*(::|=)")

# Swift — @main on an App/struct indicates the entry point.
_SWIFT_MAIN_ATTR = re.compile(r"^\s*@main\b")

# Swift — Vapor route registration: `app.get("/path") { req in ... }`
# and related `.post/.put/.delete/.patch` on any receiver.
_SWIFT_VAPOR_ROUTE = re.compile(
r"^\s*[A-Za-z_]\w*\.(get|post|put|patch|delete|on)\s*\(",
)

# Objective-C — AppDelegate lifecycle selectors on UIApplicationDelegate
_OBJC_APP_DELEGATE_SELECTORS = frozenset(
{
"application:didFinishLaunchingWithOptions:",
"application:openURL:options:",
"application:continueUserActivity:restorationHandler:",
"application:performFetchWithCompletionHandler:",
}
)

_KIND_BY_NAME = {k.value: k for k in EntrypointKind}
_TRUST_BY_NAME = {t.value: t for t in TrustLevel}
_ASSET_BY_NAME = {a.value: a for a in AssetValue}
Expand Down Expand Up @@ -237,6 +256,10 @@ def _detect_for_unit(
return _detect_haskell(cache, unit, path)
if path.endswith(".erl"):
return _detect_erlang(cache, unit, path)
if path.endswith(".swift"):
return _detect_swift(cache, unit, path)
if path.endswith((".m", ".mm", ".h")):
return _detect_objc(unit)
return None


Expand Down Expand Up @@ -601,6 +624,53 @@ def _erlang_exported_names(cache: _SourceCache, path: str) -> set[str]:
return names


def _detect_swift(
cache: _SourceCache,
unit: CodeUnit,
path: str,
) -> EntrypointTag | None:
"""Detect Swift entrypoints: @main attribute + Vapor route registration."""
decorators = cache.decorators_above(path, unit.location.start_line)
for line in decorators:
if _SWIFT_MAIN_ATTR.match(line):
return EntrypointTag(
kind=EntrypointKind.USER_INPUT,
trust_level=TrustLevel.UNTRUSTED_EXTERNAL,
description="Swift @main app entrypoint",
asset_value=AssetValue.HIGH,
)
# Vapor routes are registered inside the function body rather than
# via a decorator; match handlers whose containing file has at least
# one `<receiver>.get(...)` / `.post(...)` / etc. call.
for line in cache.iter_lines(path):
if _SWIFT_VAPOR_ROUTE.match(line):
# A Vapor file: tag every function as a potential handler entry
# only if its name matches the handler closure pattern.
# Without call-graph resolution this is coarse — Vapor-handler
# detection is best done via the override file for now.
break
return None


def _detect_objc(unit: CodeUnit) -> EntrypointTag | None:
"""Detect Objective-C entrypoints: AppDelegate selectors and extern C.

AppDelegate protocol selectors are high-value attack surface —
``application:openURL:options:`` handles deep-link invocations and
``application:didFinishLaunchingWithOptions:`` is the first code
reached after launch. Their signatures are stable enough that a
name match is sufficient.
"""
if unit.name in _OBJC_APP_DELEGATE_SELECTORS:
return EntrypointTag(
kind=EntrypointKind.API,
trust_level=TrustLevel.UNTRUSTED_EXTERNAL,
description="Objective-C UIApplicationDelegate lifecycle method",
asset_value=AssetValue.HIGH,
)
return None


class _SourceCache:
"""Lazily reads and caches source files during a detection pass."""

Expand Down
7 changes: 7 additions & 0 deletions src/trailmark/parsers/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ def _collect_throw_type(
"member_expression",
"scoped_identifier",
"selector_expression",
"simple_identifier", # Swift
"navigation_expression", # Swift dot access (e.g. self.method)
}
)

Expand All @@ -187,6 +189,11 @@ def extract_call_name(node: Node) -> str:
"""Extract the function/method name from a call node."""
func = node.child_by_field_name("function")
if func is None:
# Fallback for grammars that don't label the callable (e.g. Swift):
# the callable is conventionally the first named child.
for child in node.children:
if child.type in _CALL_NAME_TYPES:
return node_text(child)
return ""
if func.type in _CALL_NAME_TYPES:
return node_text(func)
Expand Down
5 changes: 5 additions & 0 deletions src/trailmark/parsers/objc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Objective-C language parser for Trailmark."""

from trailmark.parsers.objc.parser import ObjCParser

__all__ = ["ObjCParser"]
Loading
Loading