apparmor-language-server 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ # apparmor-language-server - LSP server for AppArmor profiles
2
+ #
3
+ # Copyright (C) 2026 Alex Murray <murray.alex@gmail.com>
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ #
18
+
19
+ """AppArmor Language Server Protocol server."""
20
+
21
+ from importlib.metadata import version
22
+
23
+ __version__ = version("apparmor-language-server")
@@ -0,0 +1,24 @@
1
+ # apparmor-language-server - LSP server for AppArmor profiles
2
+ #
3
+ # Copyright (C) 2026 Alex Murray <murray.alex@gmail.com>
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ #
18
+
19
+ """Entry point: python -m apparmor_language_server [--tcp] [--host HOST] [--port PORT]"""
20
+
21
+ from .server import main
22
+
23
+ if __name__ == "__main__":
24
+ main()
@@ -0,0 +1,70 @@
1
+ # apparmor-language-server - LSP server for AppArmor profiles
2
+ #
3
+ # Copyright (C) 2026 Alex Murray <murray.alex@gmail.com>
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ #
18
+
19
+ """
20
+ AppArmor LSP – shared line/text helpers.
21
+
22
+ Tiny module so that the server, parser and diagnostics layers share one
23
+ implementation of "where does a comment start on this line?" rather than
24
+ each carrying its own copy.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+
31
+ # Lines starting with a directive (#include, #abi) use '#' as a directive
32
+ # marker, not a comment introducer.
33
+ _RE_DIRECTIVE_LINE = re.compile(r"^\s*#(include|abi)\b")
34
+
35
+ # Matches either a quoted string (to skip over its contents) or a '#' that
36
+ # starts a comment — i.e. one at the start of the line or immediately preceded
37
+ # by whitespace or a comma. The comment '#' is captured in group 1; a quoted-
38
+ # string match leaves group 1 empty so the caller can distinguish the two.
39
+ _RE_COMMENT_OR_QUOTED = re.compile(r'"[^"]*"|\'[^\']*\'|((?:^|(?<=[ \t,]))#)')
40
+
41
+
42
+ def raw_to_pos(raw: str, start_line: int, offset: int) -> tuple[int, int]:
43
+ """Convert a byte offset within *raw* to an absolute ``(line, character)`` pair.
44
+
45
+ *raw* may span multiple source lines joined with ``\\n``; each ``\\n``
46
+ advances the line counter. The character is the column of *offset*
47
+ within its source line (0-based, absolute from the left margin).
48
+ """
49
+ prefix = raw[:offset]
50
+ nl_count = prefix.count("\n")
51
+ if nl_count:
52
+ char = offset - prefix.rfind("\n") - 1
53
+ else:
54
+ char = offset
55
+ return start_line + nl_count, char
56
+
57
+
58
+ def code_end(line: str) -> int:
59
+ """Return the column at which a trailing comment begins on *line*.
60
+
61
+ Returns ``len(line)`` if the line has no trailing comment, or if the
62
+ line is itself a directive (``#include``/``#abi``) where '#' is the
63
+ directive marker rather than a comment introducer.
64
+ """
65
+ if _RE_DIRECTIVE_LINE.match(line):
66
+ return len(line)
67
+ for m in _RE_COMMENT_OR_QUOTED.finditer(line):
68
+ if m.group(1) is not None:
69
+ return m.start(1)
70
+ return len(line)
@@ -0,0 +1,300 @@
1
+ # apparmor-language-server - LSP server for AppArmor profiles
2
+ #
3
+ # Copyright (C) 2026 Alex Murray <murray.alex@gmail.com>
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ #
18
+
19
+ """
20
+ AppArmor LSP – call hierarchy provider.
21
+
22
+ Implements ``textDocument/prepareCallHierarchy``,
23
+ ``callHierarchy/incomingCalls``, and ``callHierarchy/outgoingCalls``.
24
+
25
+ Incoming calls: which profiles exec-transition (``px``, ``cx``, ``Px``, …),
26
+ ``change_profile``, or ``change_hat`` into a given profile?
27
+
28
+ Outgoing calls: which profiles does a given profile exec-transition,
29
+ ``change_profile``, or ``change_hat`` to?
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ from typing import Iterable, Iterator, Optional, Union
36
+
37
+ from lsprotocol.types import (
38
+ CallHierarchyIncomingCall,
39
+ CallHierarchyItem,
40
+ CallHierarchyOutgoingCall,
41
+ Position,
42
+ Range,
43
+ SymbolKind,
44
+ )
45
+
46
+ from .constants import translate_uri
47
+ from .nodes import (
48
+ BlockNode,
49
+ ChangeHatRuleNode,
50
+ ChangeProfileRuleNode,
51
+ DocumentNode,
52
+ FileRuleNode,
53
+ IfBlockNode,
54
+ Node,
55
+ ProfileNode,
56
+ Token,
57
+ iter_children,
58
+ walk,
59
+ )
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # Type alias for the cache snapshot taken under the lock.
64
+ _CacheSnapshot = list[tuple[str, tuple[DocumentNode, list]]]
65
+
66
+
67
+ # ── Helpers ───────────────────────────────────────────────────────────────────
68
+
69
+
70
+ def _unquote(tok: Token) -> str:
71
+ """Strip surrounding double-quotes from a token value.
72
+
73
+ The parser preserves quotes on ``change_hat`` hat names and
74
+ ``change_profile`` targets (e.g. ``change_hat "inner"`` →
75
+ ``Token('"inner"', …)``). Profile names are stored without quotes, so
76
+ the quotes must be stripped before doing a name lookup.
77
+ """
78
+ s = str(tok)
79
+ return s[1:-1] if len(s) >= 2 and s[0] == '"' and s[-1] == '"' else s
80
+
81
+
82
+ def _profile_name(profile: ProfileNode) -> str:
83
+ tok = profile.name or profile.attachment
84
+ return str(tok) if tok is not None else "(anonymous)"
85
+
86
+
87
+ def _make_item(profile: ProfileNode, uri: str) -> CallHierarchyItem:
88
+ name = _profile_name(profile)
89
+ tok = profile.name or profile.attachment
90
+ sel_range = tok.range if tok is not None else profile.range
91
+ return CallHierarchyItem(
92
+ name=name,
93
+ kind=SymbolKind.Class,
94
+ uri=translate_uri(uri),
95
+ range=profile.range,
96
+ selection_range=sel_range,
97
+ data=name,
98
+ )
99
+
100
+
101
+ def _direct_transitions(
102
+ nodes: Iterable[Node],
103
+ ) -> Iterator[tuple[Node, str, Token]]:
104
+ """Yield ``(rule, target_str, target_token)`` for every exec-transition rule
105
+ in *nodes*.
106
+
107
+ Recurses into ``IfBlockNode`` / ``QualifierBlockNode`` (same security
108
+ domain) but does *not* descend into nested ``ProfileNode`` hat children
109
+ (they are separate security domains and appear as separate call hierarchy
110
+ items).
111
+ """
112
+ for node in nodes:
113
+ if isinstance(node, ProfileNode):
114
+ continue # hat — treat as a separate profile, not a recursive descent
115
+ if isinstance(node, FileRuleNode):
116
+ tgt = node.profile_transition_target
117
+ if tgt is not None:
118
+ yield node, str(tgt), tgt
119
+ elif (
120
+ isinstance(node, ChangeProfileRuleNode) and node.target_profile is not None
121
+ ):
122
+ yield node, _unquote(node.target_profile), node.target_profile
123
+ elif isinstance(node, ChangeHatRuleNode):
124
+ for hat in node.hats:
125
+ yield node, _unquote(hat), hat
126
+ elif isinstance(node, BlockNode):
127
+ yield from _direct_transitions(node.children)
128
+ if isinstance(node, IfBlockNode) and node.else_branch is not None:
129
+ yield from _direct_transitions([node.else_branch])
130
+
131
+
132
+ def _all_transitions(
133
+ node: Union[Node, DocumentNode],
134
+ enclosing: Optional[ProfileNode] = None,
135
+ ) -> Iterator[tuple[ProfileNode, Node, str, Token]]:
136
+ """Yield ``(enclosing_profile, rule, target_str, target_token)`` for every
137
+ exec-transition rule in the subtree of *node*.
138
+
139
+ The *enclosing_profile* is the most specific ``ProfileNode`` ancestor
140
+ (outer profile or hat) so that incoming-call items are attributed to the
141
+ correct caller.
142
+ """
143
+ if isinstance(node, DocumentNode):
144
+ for child in node.children:
145
+ yield from _all_transitions(child, None)
146
+ return
147
+ if isinstance(node, ProfileNode):
148
+ enclosing = node
149
+ elif enclosing is not None:
150
+ if isinstance(node, FileRuleNode):
151
+ tgt = node.profile_transition_target
152
+ if tgt is not None:
153
+ yield enclosing, node, str(tgt), tgt
154
+ elif (
155
+ isinstance(node, ChangeProfileRuleNode) and node.target_profile is not None
156
+ ):
157
+ yield enclosing, node, _unquote(node.target_profile), node.target_profile
158
+ elif isinstance(node, ChangeHatRuleNode):
159
+ for hat in node.hats:
160
+ yield enclosing, node, _unquote(hat), hat
161
+ for child in iter_children(node):
162
+ yield from _all_transitions(child, enclosing)
163
+
164
+
165
+ def _find_profile(
166
+ cache_snapshot: _CacheSnapshot,
167
+ name: str,
168
+ ) -> Optional[tuple[ProfileNode, str]]:
169
+ """Find a ``ProfileNode`` by name or attachment across all cached documents."""
170
+ for doc_uri, (doc, _) in cache_snapshot:
171
+ for node in walk(doc):
172
+ if isinstance(node, ProfileNode):
173
+ if node.name == name or node.attachment == name:
174
+ return node, doc_uri
175
+ return None
176
+
177
+
178
+ # ── Public API ────────────────────────────────────────────────────────────────
179
+
180
+
181
+ def prepare_call_hierarchy(
182
+ doc: DocumentNode,
183
+ uri: str,
184
+ line: int,
185
+ ch: int,
186
+ cache_snapshot: _CacheSnapshot,
187
+ ) -> Optional[list[CallHierarchyItem]]:
188
+ """Return a ``CallHierarchyItem`` for the profile under the cursor, or ``None``.
189
+
190
+ Two positions resolve to a call hierarchy item:
191
+ - A profile name or attachment token — the item is that profile itself.
192
+ - An exec-transition target token — the item is the target profile (looked
193
+ up across the full workspace cache).
194
+ """
195
+ # Cursor on a profile name or attachment token
196
+ for node in walk(doc):
197
+ if not isinstance(node, ProfileNode):
198
+ continue
199
+ for tok in (node.name, node.attachment):
200
+ if (
201
+ tok is not None
202
+ and tok.range.start.line == line
203
+ and tok.range.start.character <= ch <= tok.range.end.character
204
+ ):
205
+ return [_make_item(node, uri)]
206
+
207
+ # Cursor on an exec-transition target token
208
+ for node in walk(doc):
209
+ if node.range.start.line != line:
210
+ continue
211
+ target_str: Optional[str] = None
212
+ if isinstance(node, FileRuleNode):
213
+ t = node.profile_transition_target
214
+ if t is not None and t.range.start.character <= ch <= t.range.end.character:
215
+ target_str = str(t)
216
+ elif (
217
+ isinstance(node, ChangeProfileRuleNode) and node.target_profile is not None
218
+ ):
219
+ t = node.target_profile
220
+ if t.range.start.character <= ch <= t.range.end.character:
221
+ target_str = _unquote(t)
222
+ elif isinstance(node, ChangeHatRuleNode):
223
+ for hat in node.hats:
224
+ if hat.range.start.character <= ch <= hat.range.end.character:
225
+ target_str = _unquote(hat)
226
+ break
227
+ if target_str is not None:
228
+ found = _find_profile(cache_snapshot, target_str)
229
+ if found is not None:
230
+ return [_make_item(found[0], found[1])]
231
+
232
+ return None
233
+
234
+
235
+ def get_incoming_calls(
236
+ cache_snapshot: _CacheSnapshot,
237
+ item: CallHierarchyItem,
238
+ ) -> list[CallHierarchyIncomingCall]:
239
+ """Return all profiles that exec-transition into the profile named by *item*.
240
+
241
+ Multiple rules from the same caller profile are merged into a single
242
+ ``CallHierarchyIncomingCall`` with all rule locations listed in
243
+ ``from_ranges``.
244
+ """
245
+ target_name: str = item.data if isinstance(item.data, str) else item.name
246
+ # key: "<uri>::<profile_name>" → (caller_item, [from_ranges])
247
+ callers: dict[str, tuple[CallHierarchyItem, list[Range]]] = {}
248
+ for doc_uri, (doc, _) in cache_snapshot:
249
+ for profile, _rule, tgt_str, tgt_tok in _all_transitions(doc):
250
+ if tgt_str != target_name:
251
+ continue
252
+ key = f"{doc_uri}::{_profile_name(profile)}"
253
+ if key not in callers:
254
+ callers[key] = (_make_item(profile, doc_uri), [])
255
+ callers[key][1].append(tgt_tok.range)
256
+ return [
257
+ CallHierarchyIncomingCall(from_=caller_item, from_ranges=ranges)
258
+ for caller_item, ranges in callers.values()
259
+ ]
260
+
261
+
262
+ def get_outgoing_calls(
263
+ cache_snapshot: _CacheSnapshot,
264
+ item: CallHierarchyItem,
265
+ ) -> list[CallHierarchyOutgoingCall]:
266
+ """Return all profiles that the profile named by *item* exec-transitions to.
267
+
268
+ Multiple rules pointing to the same target profile are merged into a
269
+ single ``CallHierarchyOutgoingCall`` with all rule locations in
270
+ ``from_ranges``.
271
+ """
272
+ source_name: str = item.data if isinstance(item.data, str) else item.name
273
+ found = _find_profile(cache_snapshot, source_name)
274
+ if found is None:
275
+ return []
276
+ source_profile, source_uri = found
277
+
278
+ # key: target profile name → (target_item, [from_ranges])
279
+ targets: dict[str, tuple[CallHierarchyItem, list[Range]]] = {}
280
+ for _rule, tgt_str, tgt_tok in _direct_transitions(source_profile.children):
281
+ tgt_found = _find_profile(cache_snapshot, tgt_str)
282
+ if tgt_found is not None:
283
+ tgt_item = _make_item(tgt_found[0], tgt_found[1])
284
+ else:
285
+ # Target not in cache — synthesise a placeholder item.
286
+ tgt_item = CallHierarchyItem(
287
+ name=tgt_str,
288
+ kind=SymbolKind.Class,
289
+ uri=translate_uri(source_uri),
290
+ range=Range(Position(0, 0), Position(0, 0)),
291
+ selection_range=Range(Position(0, 0), Position(0, 0)),
292
+ data=tgt_str,
293
+ )
294
+ if tgt_str not in targets:
295
+ targets[tgt_str] = (tgt_item, [])
296
+ targets[tgt_str][1].append(tgt_tok.range)
297
+ return [
298
+ CallHierarchyOutgoingCall(to=tgt_item, from_ranges=ranges)
299
+ for tgt_item, ranges in targets.values()
300
+ ]