appjail 0.1.0__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.
appjail/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # SPDX-FileCopyrightText: 2026 Alberto P
2
+ # SPDX-License-Identifier: MPL-2.0
appjail/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2026 Alberto P
2
+ # SPDX-License-Identifier: MPL-2.0
3
+
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ sys.exit(main())
appjail/appimage.py ADDED
@@ -0,0 +1,278 @@
1
+ # SPDX-FileCopyrightText: 2026 Alberto P
2
+ # SPDX-License-Identifier: MPL-2.0
3
+
4
+ """AppImage inspection, jailed extraction and validation of the extracted tree."""
5
+
6
+ import hashlib
7
+ import os
8
+ import shutil
9
+ import stat
10
+ import struct
11
+ from pathlib import Path
12
+
13
+ from . import jail
14
+ from .textsafe import clean
15
+
16
+ ELF_MAGIC = b"\x7fELF"
17
+ MAX_EXTRACTED_BYTES = 4 * 1024 ** 3
18
+ MAX_EXTRACTED_ENTRIES = 500_000
19
+ MAX_SYMLINK_HOPS = 40
20
+ UNSQUASHFS = "/usr/bin/unsquashfs"
21
+
22
+
23
+ class NotAnAppImage(Exception):
24
+ pass
25
+
26
+
27
+ class UnsupportedImage(Exception):
28
+ """A valid image appjail does not handle (DwarFS, unknown filesystem)."""
29
+
30
+
31
+ class UnsafeTree(Exception):
32
+ """The extracted tree violates a rule (escaping links, special files, no entry point)."""
33
+
34
+
35
+ class ExtractError(Exception):
36
+ pass
37
+
38
+
39
+ # What a caller of extract() or locate_fs() has to handle (jail failures are JailError and are separate).
40
+ EXTRACT_ERRORS = (UnsupportedImage, ExtractError, UnsafeTree)
41
+
42
+
43
+ def sha256_file(path):
44
+ h = hashlib.sha256()
45
+ with open(path, "rb") as f:
46
+ for chunk in iter(lambda: f.read(1 << 20), b""):
47
+ h.update(chunk)
48
+ return h.hexdigest()
49
+
50
+
51
+ def tree_digest(root):
52
+ """sha256 over every path, type, permission bits, file content and link target of a tree.
53
+
54
+ Nothing is followed: a symlink contributes its target text only. Detects any later change
55
+ to the extracted tree, including added, removed or re-linked entries.
56
+ """
57
+ h = hashlib.sha256()
58
+ root = str(root)
59
+ stack = [""]
60
+ while stack:
61
+ rel = stack.pop()
62
+ with os.scandir(os.path.join(root, rel)) as it:
63
+ entries = sorted(it, key=lambda e: e.name)
64
+ for e in entries:
65
+ path = os.path.join(rel, e.name)
66
+ st = e.stat(follow_symlinks=False)
67
+ if stat.S_ISLNK(st.st_mode):
68
+ h.update(f"L {path}\0{os.readlink(e.path)}\0".encode("utf-8", "surrogateescape"))
69
+ elif stat.S_ISDIR(st.st_mode):
70
+ h.update(f"D {path}\0{stat.S_IMODE(st.st_mode):o}\0".encode("utf-8", "surrogateescape"))
71
+ stack.append(path)
72
+ else:
73
+ h.update(f"F {path}\0{stat.S_IMODE(st.st_mode):o}\0{st.st_size}\0".encode("utf-8", "surrogateescape"))
74
+ if stat.S_ISREG(st.st_mode):
75
+ fd = os.open(e.path, os.O_RDONLY | os.O_NOFOLLOW)
76
+ with os.fdopen(fd, "rb") as f:
77
+ for chunk in iter(lambda: f.read(1 << 20), b""):
78
+ h.update(chunk)
79
+ return h.hexdigest()
80
+
81
+
82
+ def appimage_type(path):
83
+ """Return 2 for a type-2 AppImage; raise NotAnAppImage otherwise (type 1 is unsupported)."""
84
+ with open(path, "rb") as f:
85
+ head = f.read(11)
86
+ if len(head) < 11 or head[:4] != ELF_MAGIC:
87
+ raise NotAnAppImage("not an ELF executable")
88
+ if head[8:10] != b"AI":
89
+ raise NotAnAppImage("no AppImage marker (not an AppImage)")
90
+ if head[10] == 2:
91
+ return 2
92
+ raise NotAnAppImage(f"AppImage type {head[10]} is not supported (only type 2)")
93
+
94
+
95
+ def locate_fs(path):
96
+ """(kind, offset) of the embedded filesystem: "squashfs" or "dwarfs", right after the ELF runtime."""
97
+ size = os.path.getsize(path)
98
+ with open(path, "rb") as f:
99
+ head = f.read(64)
100
+ if len(head) < 64 or head[:4] != ELF_MAGIC or head[4] != 2 or head[5] != 1:
101
+ raise UnsupportedImage("only 64-bit little-endian ELF runtimes are supported")
102
+ shoff, = struct.unpack_from("<Q", head, 0x28)
103
+ shentsize, shnum = struct.unpack_from("<HH", head, 0x3A)
104
+ offset = shoff + shentsize * shnum
105
+ if not 64 <= offset < size:
106
+ raise UnsupportedImage("cannot locate the embedded filesystem")
107
+ f.seek(offset)
108
+ magic = f.read(8)
109
+ if magic[:4] == b"hsqs":
110
+ return "squashfs", offset
111
+ if magic[:6] == b"DWARFS":
112
+ return "dwarfs", offset
113
+ raise UnsupportedImage("unknown embedded filesystem")
114
+
115
+
116
+ def check_tree(root):
117
+ """Validate an extracted tree and make it safe to handle. Never follows symlinks.
118
+
119
+ Only regular files, directories and symlinks are allowed. Set-id and sticky bits are
120
+ cleared and directories are made owner-accessible so the tree can always be removed.
121
+ Sizes are apparent sizes, so sparse files cannot hide a huge tree. Returns (entries, bytes).
122
+ """
123
+ root = Path(root)
124
+ count = total = 0
125
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
126
+ os.chmod(dirpath, (os.lstat(dirpath).st_mode & 0o777) | 0o700)
127
+ for name in dirnames + filenames:
128
+ p = os.path.join(dirpath, name)
129
+ st = os.lstat(p)
130
+ count += 1
131
+ if stat.S_ISLNK(st.st_mode) or stat.S_ISDIR(st.st_mode):
132
+ continue
133
+ if not stat.S_ISREG(st.st_mode):
134
+ raise UnsafeTree(f"special file in image: {clean(os.path.relpath(p, root), 200)}")
135
+ total += st.st_size
136
+ if st.st_mode & 0o7000:
137
+ os.chmod(p, st.st_mode & 0o777)
138
+ if total > MAX_EXTRACTED_BYTES or count > MAX_EXTRACTED_ENTRIES:
139
+ raise UnsafeTree("extracted tree is too large")
140
+ return count, total
141
+
142
+
143
+ def resolve_entry(root, rel="AppRun"):
144
+ """Resolve rel inside root as the jail would see it, refusing anything that leaves root.
145
+
146
+ Relative symlinks are followed; absolute targets and '..' above the root are rejected.
147
+ Returns the path of an executable regular file.
148
+ """
149
+ root = os.path.realpath(root)
150
+ parts = [p for p in rel.split("/") if p]
151
+ resolved = []
152
+ hops = 0
153
+ while parts:
154
+ part = parts.pop(0)
155
+ if part == ".":
156
+ continue
157
+ if part == "..":
158
+ if not resolved:
159
+ raise UnsafeTree("entry point path escapes the image")
160
+ resolved.pop()
161
+ continue
162
+ candidate = os.path.join(root, *resolved, part)
163
+ try:
164
+ st = os.lstat(candidate)
165
+ except FileNotFoundError:
166
+ raise UnsafeTree(f"{rel} not found in the image") from None
167
+ if stat.S_ISLNK(st.st_mode):
168
+ hops += 1
169
+ target = os.readlink(candidate)
170
+ if hops > MAX_SYMLINK_HOPS:
171
+ raise UnsafeTree("too many symlinks resolving the entry point")
172
+ if target.startswith("/"):
173
+ raise UnsafeTree(f"{rel} resolves through an absolute symlink ({clean(target, 100)})")
174
+ parts = [p for p in target.split("/") if p] + parts
175
+ else:
176
+ resolved.append(part)
177
+ final = os.path.join(root, *resolved)
178
+ st = os.lstat(final)
179
+ if not stat.S_ISREG(st.st_mode) or not st.st_mode & 0o111:
180
+ raise UnsafeTree(f"{rel} is not an executable file")
181
+ return final
182
+
183
+
184
+ def remove_tree(path):
185
+ """Delete a tree that may contain read-only directories. Never follows symlinks."""
186
+ path = str(path)
187
+ for dirpath, _, _ in os.walk(path, followlinks=False):
188
+ try:
189
+ if stat.S_ISDIR(os.lstat(dirpath).st_mode):
190
+ os.chmod(dirpath, 0o700)
191
+ except OSError:
192
+ pass
193
+ shutil.rmtree(path, ignore_errors=True)
194
+
195
+
196
+ def extract_dwarfs(appimage, dest, offset):
197
+ """DwarFS image: extract with the pinned, hash-checked dwarfs-universal tool, inside the jail."""
198
+ from . import tools
199
+ try:
200
+ tool = tools.require("dwarfs")
201
+ except tools.ToolError as e:
202
+ raise ExtractError(str(e)) from e
203
+ dest = Path(dest)
204
+ if dest.exists():
205
+ raise ExtractError(f"{dest} already exists")
206
+ work = dest.with_name(dest.name + ".tmp")
207
+ remove_tree(work)
208
+ work.mkdir(mode=0o700)
209
+ try:
210
+ (work / "root").mkdir()
211
+ cmd = ["/dwarfs-tool", "--tool=dwarfsextract", "-i", "/app.AppImage", "-O", str(offset),
212
+ "-o", "/out/root", "--skip-devices", "--skip-specials"]
213
+ proc = _run_extraction(jail.extract_args(appimage, work, cmd, tools=((tool, "/dwarfs-tool"),)), work)
214
+ if proc.returncode != 0:
215
+ raise ExtractError("extraction failed: " + clean(proc.stderr.strip() or f"exit {proc.returncode}", 400))
216
+ tree = work / "root"
217
+ stats = check_tree(tree)
218
+ resolve_entry(tree, "AppRun")
219
+ os.replace(tree, dest)
220
+ return stats
221
+ finally:
222
+ remove_tree(work)
223
+
224
+
225
+ def _run_extraction(args, work):
226
+ """Run an extraction jail, watching its output. Killing it for size and finding the finished tree too large
227
+ are the same verdict, so both raise UnsafeTree whichever the watcher's poll happens to catch."""
228
+ try:
229
+ return jail.run(args, timeout=900, watch=(work, MAX_EXTRACTED_BYTES, MAX_EXTRACTED_ENTRIES))
230
+ except jail.OutputTooLarge:
231
+ raise UnsafeTree("extracted tree is too large") from None
232
+
233
+
234
+ def have_unsquashfs():
235
+ return os.access(UNSQUASHFS, os.X_OK)
236
+
237
+
238
+ def extract(appimage, dest, runtime_fallback=False):
239
+ """Extract appimage into dest (must not exist) inside a no-network jail, then validate it.
240
+
241
+ Prefers unsquashfs on the filesystem offset, so the AppImage runtime never runs. With
242
+ runtime_fallback the image's own --appimage-extract is run, still inside the jail.
243
+ Returns (entries, bytes). On any failure dest is not left behind.
244
+ """
245
+ appimage, dest = Path(appimage), Path(dest)
246
+ kind, offset = locate_fs(appimage)
247
+ if kind == "dwarfs":
248
+ return extract_dwarfs(appimage, dest, offset)
249
+ if not have_unsquashfs() and not runtime_fallback:
250
+ raise ExtractError("unsquashfs is not installed (apt install squashfs-tools)")
251
+ if dest.exists():
252
+ raise ExtractError(f"{dest} already exists")
253
+ work = dest.with_name(dest.name + ".tmp")
254
+ remove_tree(work)
255
+ work.mkdir(mode=0o700)
256
+ try:
257
+ if have_unsquashfs():
258
+ cmd = [UNSQUASHFS, "-no-progress", "-no-xattrs", "-p", "2", "-o", str(offset),
259
+ "-d", "/out/root", "/app.AppImage"]
260
+ tree = work / "root"
261
+ else:
262
+ # The runtime must be executable inside the jail: use a private copy.
263
+ copy = work / "app.AppImage"
264
+ shutil.copyfile(appimage, copy)
265
+ copy.chmod(0o700)
266
+ cmd = ["/out/app.AppImage", "--appimage-extract"]
267
+ tree = work / "squashfs-root"
268
+ proc = _run_extraction(jail.extract_args(appimage, work, cmd), work)
269
+ if proc.returncode != 0:
270
+ raise ExtractError("extraction failed: " + clean(proc.stderr.strip() or f"exit {proc.returncode}", 400))
271
+ if not tree.is_dir():
272
+ raise ExtractError("extraction produced no files")
273
+ stats = check_tree(tree)
274
+ resolve_entry(tree, "AppRun")
275
+ os.replace(tree, dest)
276
+ return stats
277
+ finally:
278
+ remove_tree(work)
appjail/attest.py ADDED
@@ -0,0 +1,103 @@
1
+ # SPDX-FileCopyrightText: 2026 Alberto P
2
+ # SPDX-License-Identifier: MPL-2.0
3
+
4
+ """Build-attestation verification (evidence tier 1) with the pinned GitHub CLI, inside a jail.
5
+
6
+ appjail fetches the Sigstore bundles itself (`github.Client.attestation_bundles`) and hands them to
7
+ `gh attestation verify --bundle`, which checks the signature chain against the Sigstore trusted root
8
+ (fetched over TUF, hence the network), that the bundle covers the exact downloaded bytes, and that the
9
+ signing identity belongs to the repository. `gh` runs from `appjail.tools` (size and sha256 pinned),
10
+ sees only the subject, the bundles and a scratch directory, and receives no token (it requires one to
11
+ be set but never uses it with `--bundle`). On top of gh's verdict appjail re-reads the certificate
12
+ fields and requires GitHub Actions as issuer, the exact repository, and, when known, the pinned
13
+ owner account id: a name is not enough, an account id is.
14
+ """
15
+
16
+ import json
17
+ import secrets
18
+ import shutil
19
+ from dataclasses import dataclass
20
+
21
+ from . import jail, store, tools
22
+ from .textsafe import clean
23
+
24
+ ISSUER = "https://token.actions.githubusercontent.com"
25
+ TIMEOUT = 180
26
+
27
+
28
+ class AttestError(Exception):
29
+ """The attestation could not be verified (tool missing, no valid bundle, identity mismatch)."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Verified:
34
+ repo: str # https://github.com/owner/repo
35
+ workflow: str # signing workflow with ref, from the certificate
36
+ ref: str
37
+ run_url: str
38
+ owner_id: str
39
+ repo_id: str
40
+
41
+
42
+ def _args(gh, subject, bundles, scratch):
43
+ return (jail.common_args() + ["--share-net"] + jail.etc_args(private_net=True)
44
+ + ["--ro-bind", str(gh), "/gh", "--ro-bind", str(subject), "/subject",
45
+ "--ro-bind", str(bundles), "/bundle.jsonl", "--bind", str(scratch), "/scratch",
46
+ "--remount-ro", "/", "--chdir", "/scratch",
47
+ "--setenv", "HOME", "/scratch", "--setenv", "GH_TOKEN", "unused",
48
+ "--setenv", "GH_PROMPT_DISABLED", "1", "--setenv", "GH_NO_UPDATE_NOTIFIER", "1", "--"])
49
+
50
+
51
+ def check_results(results, full_name, owner_id=None):
52
+ """First verified result whose certificate matches the repository (and owner id); else AttestError."""
53
+ wanted = f"https://github.com/{full_name}".lower()
54
+ problems = []
55
+ for item in results if isinstance(results, list) else []:
56
+ try:
57
+ cert = item["verificationResult"]["signature"]["certificate"]
58
+ repo = str(cert["sourceRepositoryURI"])
59
+ if repo.lower() != wanted:
60
+ problems.append(f"signed for {repo}")
61
+ elif cert.get("issuer") != ISSUER:
62
+ problems.append(f"unexpected issuer {cert.get('issuer')}")
63
+ elif owner_id is not None and str(cert.get("sourceRepositoryOwnerIdentifier")) != str(owner_id):
64
+ problems.append("the signing repository's owner account differs from the pinned owner")
65
+ else:
66
+ return Verified(repo, str(cert.get("buildSignerURI", "")), str(cert.get("sourceRepositoryRef", "")),
67
+ str(cert.get("runInvocationURI", "")), str(cert.get("sourceRepositoryOwnerIdentifier", "")),
68
+ str(cert.get("sourceRepositoryIdentifier", "")))
69
+ except (KeyError, TypeError):
70
+ problems.append("unreadable verification result")
71
+ raise AttestError("no attestation matches this repository: " + ("; ".join(sorted(set(problems))) or "none verified"))
72
+
73
+
74
+ def verify(subject, full_name, bundles, owner_id=None, run=jail.run):
75
+ """Verify the downloaded file against its bundles. Returns Verified or raises AttestError."""
76
+ if not bundles:
77
+ raise AttestError("GitHub holds no attestation for this file")
78
+ try:
79
+ gh = tools.require("gh")
80
+ except tools.ToolError as e:
81
+ raise AttestError(str(e)) from e
82
+ work = store.staging_root() / ("attest-" + secrets.token_hex(8))
83
+ work.mkdir(parents=True, mode=0o700)
84
+ try:
85
+ bundle_file = work / "bundle.jsonl"
86
+ bundle_file.write_text("".join(json.dumps(b) + "\n" for b in bundles), encoding="utf-8")
87
+ scratch = work / "scratch"
88
+ scratch.mkdir(mode=0o700)
89
+ cmd = ["/gh", "attestation", "verify", "/subject", "--repo", full_name, "--bundle", "/bundle.jsonl",
90
+ "--format", "json"]
91
+ try:
92
+ proc = run(_args(gh, subject, bundle_file, scratch) + cmd, timeout=TIMEOUT)
93
+ except jail.JailError as e:
94
+ raise AttestError(str(e)) from e
95
+ if proc.returncode != 0:
96
+ raise AttestError("verification failed: " + clean(proc.stderr.strip() or f"exit {proc.returncode}", 300))
97
+ try:
98
+ results = json.loads(proc.stdout)
99
+ except ValueError as e:
100
+ raise AttestError("verification output was not understood") from e
101
+ return check_results(results, full_name, owner_id)
102
+ finally:
103
+ shutil.rmtree(work, ignore_errors=True)
appjail/cli.py ADDED
@@ -0,0 +1,137 @@
1
+ # SPDX-FileCopyrightText: 2026 Alberto P
2
+ # SPDX-License-Identifier: MPL-2.0
3
+
4
+ """Command line interface: the parser and the dispatcher. The commands live in cmd_*.py.
5
+
6
+ The names re-exported here are the ones tests and the `appjail` console script (pyproject.toml) import from `appjail.cli`.
7
+ """
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from . import github, index, indexsync, jail, manifest, runner, tools, updater
13
+ from .cmd_add import cmd_add
14
+ from .cmd_apps import cmd_history, cmd_list, cmd_perms, cmd_remove, cmd_tools
15
+ from .cmd_index import cmd_index_rollback, cmd_index_status, cmd_index_update, cmd_search
16
+ from .cmd_update import cmd_update
17
+ from .console import Abort, Console
18
+ from .permargs import PERMISSION_FLAGS, dirs_from_args, files_from_args
19
+
20
+
21
+ def build_parser():
22
+ p = argparse.ArgumentParser(prog="appjail", description="Sandboxed AppImage manager")
23
+ sub = p.add_subparsers(dest="command", required=True)
24
+ add = sub.add_parser("add", help="add an app from a GitHub owner/repo")
25
+ add.add_argument("source", help="GitHub owner/repo")
26
+ add.add_argument("--name", help="local app name (default: repo name)")
27
+ add.add_argument("--prerelease", action="store_true", help="allow pre-releases")
28
+ add.add_argument("--tag", help="use this release tag instead of the latest release")
29
+ add.add_argument("--dir", action="append", default=[], metavar="PATH[:ro|:rw]",
30
+ help="let the app see this directory (read-only unless :rw); repeatable")
31
+ add.add_argument("--dbus", action="append", default=[], metavar="NAME",
32
+ help="let the app talk to this session-bus name through a filtering proxy; repeatable")
33
+ add.add_argument("--dbus-own", action="append", default=[], metavar="NAME",
34
+ help="let the app own this session-bus name (MPRIS player, single-instance name); repeatable")
35
+ add.add_argument("--portal", action="append", default=[], metavar="NAME",
36
+ help="also allow this privileged portal interface (needs --allow portals); repeatable")
37
+ add.add_argument("--allow", action="append", default=[], choices=[f for f in PERMISSION_FLAGS if f not in ("audio", "gpu")],
38
+ help="grant a permission (repeatable); audio and gpu are on by default")
39
+ add.add_argument("--no-audio", action="store_true", help="do not grant audio")
40
+ add.add_argument("--no-gpu", action="store_true", help="do not grant GPU access")
41
+ add.add_argument("--display", choices=manifest.DISPLAYS, default="wayland")
42
+ search = sub.add_parser("search", help="search the local AM index (suggestions only, never installs)")
43
+ search.add_argument("keywords", nargs="+")
44
+ search.add_argument("--limit", type=int, default=20, help="maximum results (default 20)")
45
+ search.add_argument("--all", action="store_true", help="show every match")
46
+ idx = sub.add_parser("index", help="manage the local AM search index")
47
+ isub = idx.add_subparsers(dest="index_command", required=True)
48
+ upd = isub.add_parser("update", help="fetch the AM index, review the changes, then pin it")
49
+ upd.add_argument("--commit", default="HEAD", help="fetch this commit (40 hex digits) instead of the remote HEAD")
50
+ isub.add_parser("rollback", help="go back to the previous index snapshot")
51
+ isub.add_parser("status", help="show the pinned index")
52
+ upd_app = sub.add_parser("update", help="check for a newer release and install it after review and quarantine")
53
+ upd_app.add_argument("app", nargs="?", help="app name (default: every installed app)")
54
+ upd_app.add_argument("--prerelease", action="store_true", help="follow pre-releases for this run (see --channel to make it permanent)")
55
+ upd_app.add_argument("--channel", choices=manifest.CHANNELS, help="set which releases this app follows from now on: stable, prerelease, or pinned (never updated)")
56
+ upd_app.add_argument("--quarantine", type=int, default=updater.DEFAULT_QUARANTINE_DAYS, metavar="DAYS",
57
+ help="days a new release must wait after it was first seen (default %(default)s; 0 = none)")
58
+ sub.add_parser("list", help="list installed apps")
59
+ tl = sub.add_parser("tools", help="pinned helper binaries (e.g. for DwarFS AppImages)")
60
+ tsub = tl.add_subparsers(dest="tools_command", required=True)
61
+ tsub.add_parser("status", help="show which helpers are installed and intact")
62
+ tinst = tsub.add_parser("install", help="download a helper whose size and sha256 are pinned in appjail's source")
63
+ tinst.add_argument("tool", choices=sorted(tools.TOOLS))
64
+ hist = sub.add_parser("history", help="show the recorded changes to the manifests")
65
+ hist.add_argument("app", nargs="?")
66
+ hist.add_argument("--limit", type=int, default=20)
67
+ rm = sub.add_parser("remove", help="delete an app, including its private home")
68
+ rm.add_argument("app")
69
+ rm.add_argument("--yes", action="store_true", help="do not ask for confirmation")
70
+ perms = sub.add_parser("perms", help="show or change an app's permissions (the pinned release is unchanged)")
71
+ perms.add_argument("app")
72
+ perms.add_argument("--allow", action="append", default=[], choices=PERMISSION_FLAGS)
73
+ perms.add_argument("--deny", action="append", default=[], choices=PERMISSION_FLAGS)
74
+ perms.add_argument("--display", choices=manifest.DISPLAYS)
75
+ perms.add_argument("--add-dir", action="append", default=[], metavar="PATH[:ro|:rw]")
76
+ perms.add_argument("--rm-dir", action="append", default=[], metavar="PATH")
77
+ perms.add_argument("--add-dbus", action="append", default=[], metavar="NAME", help="allow talking to this session-bus name")
78
+ perms.add_argument("--rm-dbus", action="append", default=[], metavar="NAME")
79
+ perms.add_argument("--add-dbus-own", action="append", default=[], metavar="NAME", help="allow owning this session-bus name")
80
+ perms.add_argument("--rm-dbus-own", action="append", default=[], metavar="NAME")
81
+ perms.add_argument("--add-portal", action="append", default=[], metavar="NAME", help="also allow this privileged portal interface")
82
+ perms.add_argument("--rm-portal", action="append", default=[], metavar="NAME")
83
+ perms.add_argument("--yes", action="store_true", help="do not ask for confirmation")
84
+ run = sub.add_parser("run", help="run an installed app in its jail")
85
+ run.add_argument("name")
86
+ run.add_argument("--dir", action="append", default=[], metavar="PATH[:ro|:rw]",
87
+ help="grant this directory for this run only (manifest unchanged); repeatable")
88
+ run.add_argument("--file", action="append", default=[], metavar="PATH[:rw]",
89
+ help="bind just this file for this run (read-only unless :rw) and pass its in-jail path to the app; repeatable")
90
+ return p
91
+
92
+
93
+ def main(argv=None, client=None, console=None):
94
+ argv = list(sys.argv[1:] if argv is None else argv)
95
+ app_args = []
96
+ if "--" in argv: # everything after -- belongs to the app
97
+ i = argv.index("--")
98
+ argv, app_args = argv[:i], argv[i + 1:]
99
+ args = build_parser().parse_args(argv)
100
+ console = console or Console()
101
+ try:
102
+ if args.command == "add":
103
+ if not manifest.SOURCE_RE.fullmatch(args.source):
104
+ raise Abort("source must be a GitHub owner/repo")
105
+ cmd_add(args, client or github.Client(), console)
106
+ elif args.command == "index":
107
+ if args.index_command == "update":
108
+ cmd_index_update(args, console)
109
+ elif args.index_command == "rollback":
110
+ cmd_index_rollback(console)
111
+ else:
112
+ cmd_index_status(console)
113
+ elif args.command == "search":
114
+ cmd_search(args, console)
115
+ elif args.command == "update":
116
+ return cmd_update(args, client or github.Client(), console)
117
+ elif args.command == "list":
118
+ cmd_list(console)
119
+ elif args.command == "tools":
120
+ cmd_tools(args, console)
121
+ elif args.command == "history":
122
+ cmd_history(args, console)
123
+ elif args.command == "remove":
124
+ cmd_remove(args, console)
125
+ elif args.command == "perms":
126
+ cmd_perms(args, console)
127
+ elif args.command == "run":
128
+ extra_dirs = dirs_from_args(args.dir)
129
+ extra_files = files_from_args(args.file)
130
+ return runner.launch(args.name, app_args, say=console.out, extra_dirs=extra_dirs, extra_files=extra_files)
131
+ except Abort as e:
132
+ console.out(str(e))
133
+ return 1
134
+ except (github.GitHubError, jail.JailError, jail.RunError, manifest.ManifestError, index.IndexProblem, indexsync.IndexRefused, OSError) as e:
135
+ console.out(f"error: {e}")
136
+ return 1
137
+ return 0