From d618957b68d02b2bf272c9356194d6d73d9dd191 Mon Sep 17 00:00:00 2001 From: Ludwig Lehnert Date: Wed, 19 Aug 2026 10:07:25 +0000 Subject: [PATCH] better trash; no favicon --- README.md | 6 +- app/backup_to_destination.py | 88 +++++++++++++++++------------ app/trash.py | 15 ++++- app/web/favicon.svg | 1 - app/web/index.html | 1 - app/web_ui.py | 1 - dev/ad-dc.Dockerfile | 7 +++ dev/ad-entrypoint.sh | 22 +++++++- dev/e2e.py | 60 ++++++++++++++++++-- etc/samba/smb.conf | 9 ++- scripts/dev | 1 - tests/test_backup_to_destination.py | 18 ++++++ tests/test_trash.py | 46 +++++++++++++++ tests/test_web_ui.py | 42 +++++++++++++- 14 files changed, 263 insertions(+), 54 deletions(-) delete mode 100644 app/web/favicon.svg diff --git a/README.md b/README.md index b559b71..4a4f434 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ The launcher builds the current application and starts an isolated, run-scoped n - the actual file-server image, joined to the dummy domain; - a continuous SMB client that exercises reads, writes, renames, and deletions, including activity from an excluded dummy service account. +The disposable AD DC is compatible with rootless Podman: its development-only internal ID range stays inside the standard 65,536-entry user namespace, and SYSVOL ACL metadata is stored in `xattr_tdb` because an unprivileged container cannot write the `security.NTACL` namespace. These compatibility settings apply only to `dev/ad-dc.Dockerfile`; the production file-server image and real AD remain unchanged. + The preview starts with group, Private, and FSLogix data. The client keeps current activity moving, while a real backup runs immediately and repeats in the background. Open the URL and use the credentials printed by the launcher. Defaults are: ```text @@ -138,7 +140,7 @@ Password: PreviewAdmin123! The generated CA is intentionally disposable. `scripts/dev` prints the temporary root certificate path so it can be trusted only for the duration of that run. Ctrl-C stops and removes all run-scoped containers, volumes, the network, and the temporary root. A watchdog performs the same cleanup if the parent script is killed. -The dummy DC alone receives `SYS_ADMIN`, which Samba needs to write Windows ACL xattrs while provisioning `SYSVOL`; the application container receives no extra capability. +Neither the dummy DC nor the application container receives extra capabilities. Useful overrides: @@ -292,7 +294,7 @@ The runner returns non-zero on the first failed assertion, prints bounded logs f Deletes through the `Private`, `Data`, and `FSLogix` SMB shares are intercepted by Samba's recycle VFS and moved into `.trash/` on the same source volume. Moving on the same filesystem avoids copying even large profile containers. The original directory tree is retained, repeated deletions receive versioned names, and the deletion time is stored as the recycled file's modification time. -The repository root is owned by root, vetoed from SMB access, and not included in per-user/per-group usage rows. Every hour—and once during container startup—the cleanup job permanently removes entries older than seven days. `TRASH_RETENTION_DAYS` defaults to `7` and may be set from `1` to `365`. +The repository root is owned by root, vetoed from SMB access, and excluded from usage accounting and remote backup snapshots. Temporary `*.tmp` files and document lock files beginning with `~$` are deleted normally instead of being retained; cleanup also purges any such entries left by an older configuration. Every hour—and once during container startup—the cleanup job permanently removes entries older than seven days. `TRASH_RETENTION_DAYS` defaults to `7` and may be set from `1` to `365`. Domain Admins can use **Papierkorb** in the web console to filter and list retained files, stream-download them, or restore them to their original path. Restore is a same-filesystem link/unlink operation, so it is fast for large files and preserves file metadata. It never overwrites an existing file; a conflict is reported and the retained copy remains in the bin. diff --git a/app/backup_to_destination.py b/app/backup_to_destination.py index 2277997..60d8ca2 100644 --- a/app/backup_to_destination.py +++ b/app/backup_to_destination.py @@ -27,6 +27,10 @@ DEFAULT_BACKUP_STATUS_FILE = "/state/backup-status.json" DEFAULT_ARCHIVE_TEMP_DIR = "/tmp" GROUPS_SOURCE_PATH = "/data/groups" GROUP_ARCHIVE_CATEGORIES = ("data", "archive") +TRASH_DIRECTORY = ".trash" +TRASH_EXCLUDED_DESTINATIONS = frozenset( + {"data/private", "data/groups", "data/fslogix"} +) DEFAULT_PROGRESS_MODE = "auto" DEFAULT_PROGRESS_INTERVAL_SECONDS = 10 PROGRESS_BAR_WIDTH = 28 @@ -422,17 +426,23 @@ def parse_data_size(raw_value: str) -> Optional[int]: return int(round(normalize_size_number(match.group("value")) * multiplier)) -def measure_path_bytes(path: str) -> int: +def measure_path_bytes(path: str, *, exclude_root_trash: bool = False) -> int: total = 0 - stack = [path] + stack = [(path, True)] while stack: - current = stack.pop() + current, is_root = stack.pop() try: with os.scandir(current) as entries: for entry in entries: try: + if ( + exclude_root_trash + and is_root + and entry.name == TRASH_DIRECTORY + ): + continue if entry.is_dir(follow_symlinks=False): - stack.append(entry.path) + stack.append((entry.path, False)) elif entry.is_file(follow_symlinks=False): total += entry.stat(follow_symlinks=False).st_size except OSError as exc: @@ -445,8 +455,11 @@ def measure_path_bytes(path: str) -> int: def measure_backup_payload(sources: List[Tuple[str, str]]) -> Tuple[Dict[str, int], int]: source_sizes: Dict[str, int] = {} total = 0 - for source_path, _destination_path in sources: - size = measure_path_bytes(source_path) + for source_path, destination_path in sources: + size = measure_path_bytes( + source_path, + exclude_root_trash=destination_path in TRASH_EXCLUDED_DESTINATIONS, + ) source_sizes[source_path] = size total += size return source_sizes, total @@ -1082,6 +1095,9 @@ def prepare_group_archives( key=lambda entry: entry.name.casefold(), ) for entry in members: + if entry.name == TRASH_DIRECTORY: + log(f"Excluding retained trash from backup source {category}") + continue target = os.path.join(target_category, entry.name) if entry.is_dir(follow_symlinks=False) and not entry.is_symlink(): relative_name = f"{category}/{entry.name}" @@ -1407,25 +1423,25 @@ class RcloneBackend: interval_seconds=progress_interval_seconds, interactive=interactive_progress, ) - run_streaming_command( - [ - "rclone", - "sync", - f"{source_path}/", - f"backup:{remote_path}", - "--create-empty-src-dirs", - "--progress", - "--stats", - f"{progress_interval_seconds}s", - "--transfers", - str(MAX_PARALLEL_FILE_UPLOADS), - "--log-level", - "INFO", - "--config", - self.config_path, - ], - progress=progress, - ) + command = [ + "rclone", + "sync", + f"{source_path}/", + f"backup:{remote_path}", + "--create-empty-src-dirs", + "--progress", + "--stats", + f"{progress_interval_seconds}s", + "--transfers", + str(MAX_PARALLEL_FILE_UPLOADS), + "--log-level", + "INFO", + "--config", + self.config_path, + ] + if destination_path in TRASH_EXCLUDED_DESTINATIONS: + command.extend(["--exclude", f"/{TRASH_DIRECTORY}/**"]) + run_streaming_command(command, progress=progress) def write_marker(self, snapshot_name: str) -> None: marker_path = join_path(self._snapshot_root(snapshot_name), ".backup_complete") @@ -1532,17 +1548,19 @@ class RsyncBackend: interval_seconds=progress_interval_seconds, interactive=interactive_progress, ) + command = [ + "rsync", + "-a", + "--delete", + "--mkpath", + "--progress", + "--outbuf=L", + ] + if destination_path in TRASH_EXCLUDED_DESTINATIONS: + command.append(f"--exclude=/{TRASH_DIRECTORY}/") + command.extend([f"{source_path}/", f"{target}/"]) run_streaming_command( - [ - "rsync", - "-a", - "--delete", - "--mkpath", - "--progress", - "--outbuf=L", - f"{source_path}/", - f"{target}/", - ], + command, env=self.command_env, progress=progress, ) diff --git a/app/trash.py b/app/trash.py index 7866ed9..444e009 100644 --- a/app/trash.py +++ b/app/trash.py @@ -15,6 +15,13 @@ DEFAULT_RETENTION_DAYS = 7 VERSION_PREFIX_RE = re.compile(r"^Copy #\d+ of ", re.IGNORECASE) +def excluded_filename(filename: str) -> bool: + """Return whether a transient file must never be retained.""" + unversioned = VERSION_PREFIX_RE.sub("", filename, count=1) + folded = unversioned.casefold() + return folded.endswith(".tmp") or folded.startswith("~$") + + def retention_days() -> int: try: value = int(os.getenv("TRASH_RETENTION_DAYS", str(DEFAULT_RETENTION_DAYS))) @@ -147,6 +154,8 @@ def _resolved_item(item_id: str) -> Tuple[str, str, str, List[str], os.stat_resu share_root = share_roots()[share] repository = trash_root(share_root) parts = _relative_parts(relative_path) + if excluded_filename(parts[-1]): + raise FileNotFoundError("Papierkorbeintrag ist ausgeschlossen") parent_fd = _open_directory_chain(repository, parts[:-1]) try: info = os.stat(parts[-1], dir_fd=parent_fd, follow_symlinks=False) @@ -217,6 +226,8 @@ def list_items( continue subdirectories[:] = safe_subdirectories for filename in filenames: + if excluded_filename(filename): + continue candidate = os.path.join(directory, filename) try: info = os.lstat(candidate) @@ -370,7 +381,7 @@ def cleanup_expired(now: Optional[dt.datetime] = None) -> Dict[str, int]: candidate = os.path.join(directory, filename) try: info = os.lstat(candidate) - if info.st_mtime >= cutoff: + if info.st_mtime >= cutoff and not excluded_filename(filename): continue if not ( stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) @@ -405,7 +416,7 @@ def main() -> int: parser.error("--cleanup is required") result = cleanup_expired() print( - f"[trash] Removed {result['removed']} expired item(s) " + f"[trash] Removed {result['removed']} expired or excluded item(s) " f"({result['removedBytes']} bytes)", flush=True, ) diff --git a/app/web/favicon.svg b/app/web/favicon.svg deleted file mode 100644 index e2f31d2..0000000 --- a/app/web/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/web/index.html b/app/web/index.html index dc6111d..eb27a88 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -5,7 +5,6 @@ Dateiserver-Verwaltung - diff --git a/app/web_ui.py b/app/web_ui.py index 5d3095c..9e2c45e 100644 --- a/app/web_ui.py +++ b/app/web_ui.py @@ -1221,7 +1221,6 @@ class Handler(BaseHTTPRequestHandler): "/assets/vendor/typst/0.6.0-csp1/compiler.wasm": ("vendor/typst/compiler.wasm", "application/wasm"), "/assets/vendor/typst/0.6.0-csp1/LibertinusSerif-Regular.otf": ("vendor/typst/LibertinusSerif-Regular.otf", "font/otf"), "/assets/vendor/typst/0.6.0-csp1/LibertinusSerif-Semibold.otf": ("vendor/typst/LibertinusSerif-Semibold.otf", "font/otf"), - "/favicon.svg": ("favicon.svg", "image/svg+xml"), } if path in files: filename, content_type = files[path] diff --git a/dev/ad-dc.Dockerfile b/dev/ad-dc.Dockerfile index 27c8823..47ad9fc 100644 --- a/dev/ad-dc.Dockerfile +++ b/dev/ad-dc.Dockerfile @@ -15,6 +15,13 @@ RUN apt-get update \ tini \ && rm -rf /var/lib/apt/lists/* +# Rootless Podman maps 65,536 IDs. Keep this disposable DC's internal idmap +# inside that namespace; production AD domains must use their normal range. +RUN sed -i \ + -e 's/lowerBound: 3000000/lowerBound: 10000/' \ + -e 's/upperBound: 4000000/upperBound: 60000/' \ + /usr/share/samba/setup/idmap_init.ldif + COPY dev/ad-entrypoint.sh /usr/local/bin/preview-ad-entrypoint COPY dev/preview-client.sh /usr/local/bin/preview-client COPY dev/seed-files.sh /usr/local/bin/preview-seed-files diff --git a/dev/ad-entrypoint.sh b/dev/ad-entrypoint.sh index f74eeb6..1d54e36 100755 --- a/dev/ad-entrypoint.sh +++ b/dev/ad-entrypoint.sh @@ -21,14 +21,32 @@ done if [[ ! -s /var/lib/samba/private/sam.ldb ]]; then log "Provisioning disposable ${AD_REALM} domain" - rm -f /etc/samba/smb.conf + netbios_name=${HOSTNAME%%.*} + netbios_name=${netbios_name^^} + mkdir -p /var/lib/samba/state + cat > /etc/samba/smb.conf < int: "-U", f"{WORKGROUP}\\alice%{USER_PASSWORD}", "-c", - "cd .trash; ls", + "cd .trash", check_result=False, ) check( @@ -332,6 +332,48 @@ def main() -> int: "ordinary SMB user can browse the admin-managed trash repository", ) + announce("temporary and document-lock files bypass the recycle repository") + transient_delete = engine_run( + "exec", + CLIENT_CONTAINER, + "smbclient", + f"//files.{DNS_DOMAIN}/Data", + "-m", + "SMB3", + "-U", + f"{WORKGROUP}\\alice%{USER_PASSWORD}", + "-c", + ( + "cd Finance; cd Reports; " + "put /tmp/live-note.txt 713A292F.tmp; del 713A292F.tmp; " + 'put /tmp/live-note.txt "~$RG Eingang 2026.xlsx"; ' + 'del "~$RG Eingang 2026.xlsx"' + ), + check_result=False, + ) + check( + transient_delete.returncode == 0, + "temporary/document-lock deletion over SMB failed: " + + (transient_delete.stderr.strip() or transient_delete.stdout.strip()), + ) + transient_absent = engine_run( + "exec", + FILES_CONTAINER, + "sh", + "-ec", + ( + "test ! -e '/data/groups/data/Finance/Reports/713A292F.tmp'; " + "test ! -e '/data/groups/data/Finance/Reports/~$RG Eingang 2026.xlsx'; " + "test -z \"$(find /data/groups/data/.trash -type f " + "\\( -iname '*.tmp' -o -name '~$*' \\) -print -quit)\"" + ), + check_result=False, + ) + check( + transient_absent.returncode == 0, + "temporary or document-lock file was retained in the trash repository", + ) + announce("real Samba recycle, admin download, and conflict-safe restore") trash_response = eventually( "deleted SMB file in the seven-day trash", @@ -486,9 +528,19 @@ def main() -> int: ) check( { - "audit_events_time", - "audit_events_user_time", - "audit_events_action_time", + "audit_events_main_time", + "audit_events_main_action_time", + "audit_events_main_success_time", + "audit_events_main_user_time", + "audit_events_main_account_time", + "audit_events_main_share_time", + "audit_events_main_result_time", + "audit_events_fslogix_time", + "audit_events_fslogix_action_time", + "audit_events_fslogix_success_time", + "audit_events_fslogix_user_time", + "audit_events_fslogix_account_time", + "audit_events_fslogix_result_time", }.issubset(indexes), f"audit indexes are incomplete: {sorted(indexes)}", ) diff --git a/etc/samba/smb.conf b/etc/samba/smb.conf index e995143..86e81e5 100644 --- a/etc/samba/smb.conf +++ b/etc/samba/smb.conf @@ -42,13 +42,14 @@ read only = no browseable = yes guest ok = no - vfs objects = acl_xattr recycle full_audit + vfs objects = acl_xattr full_audit recycle recycle:repository = .trash/%U recycle:keeptree = yes recycle:versions = yes recycle:touch_mtime = yes recycle:directory_mode = 0700 recycle:subdir_mode = 0700 + recycle:exclude = *.tmp,*.TMP,~$* recycle:exclude_dir = .trash veto files = /.trash/ full_audit:prefix = %T|%u|%I|%m|%S @@ -66,13 +67,14 @@ read only = no browseable = yes guest ok = no - vfs objects = acl_xattr recycle full_audit + vfs objects = acl_xattr full_audit recycle recycle:repository = .trash/%U recycle:keeptree = yes recycle:versions = yes recycle:touch_mtime = yes recycle:directory_mode = 0700 recycle:subdir_mode = 0700 + recycle:exclude = *.tmp,*.TMP,~$* recycle:exclude_dir = .trash veto files = /.trash/ full_audit:prefix = %T|%u|%I|%m|%S @@ -101,13 +103,14 @@ read only = no browseable = yes guest ok = no - vfs objects = acl_xattr recycle full_audit + vfs objects = acl_xattr full_audit recycle recycle:repository = .trash/%U recycle:keeptree = yes recycle:versions = yes recycle:touch_mtime = yes recycle:directory_mode = 0700 recycle:subdir_mode = 0700 + recycle:exclude = *.tmp,*.TMP,~$* recycle:exclude_dir = .trash veto files = /.trash/ full_audit:prefix = %T|%u|%I|%m|%S diff --git a/scripts/dev b/scripts/dev index 3f3bf6b..e852896 100755 --- a/scripts/dev +++ b/scripts/dev @@ -260,7 +260,6 @@ printf 'starting disposable Samba AD DC: %s\n' "$dev_realm" "$engine" run -d \ --name "$dc_container" \ --hostname "dc.${dev_dns_domain}" \ - --cap-add SYS_ADMIN \ --network "$network_name" \ --ip "$dc_ip" \ -e "AD_REALM=${dev_realm}" \ diff --git a/tests/test_backup_to_destination.py b/tests/test_backup_to_destination.py index 61dd12d..66ba9d8 100644 --- a/tests/test_backup_to_destination.py +++ b/tests/test_backup_to_destination.py @@ -198,6 +198,11 @@ class ProgressParsingTests(unittest.TestCase): handle.write(b"a" * 3) with open(os.path.join(nested, "two.bin"), "wb") as handle: handle.write(b"b" * 5) + os.makedirs(os.path.join(tmpdir, ".trash", "alice")) + with open( + os.path.join(tmpdir, ".trash", "alice", "deleted.bin"), "wb" + ) as handle: + handle.write(b"x" * 100) sizes, total = backup.measure_backup_payload([(tmpdir, "data/private")]) @@ -222,6 +227,7 @@ class GroupArchiveTests(unittest.TestCase): source_root = os.path.join(tmpdir, "groups") os.makedirs(os.path.join(source_root, "data", "Finance")) os.makedirs(os.path.join(source_root, "archive", "Former")) + os.makedirs(os.path.join(source_root, "data", ".trash", "alice")) os.makedirs(os.path.join(source_root, "metadata")) with open( os.path.join(source_root, "data", "Finance", "report.txt"), @@ -241,6 +247,12 @@ class GroupArchiveTests(unittest.TestCase): encoding="utf-8", ) as handle: handle.write("preserve me") + with open( + os.path.join(source_root, "data", ".trash", "alice", "deleted.txt"), + "w", + encoding="utf-8", + ) as handle: + handle.write("retained deletion") with open( os.path.join(source_root, "metadata", "index.txt"), "w", @@ -280,6 +292,9 @@ class GroupArchiveTests(unittest.TestCase): self.assertFalse( os.path.exists(os.path.join(staged_root, "data", "Finance")) ) + self.assertFalse( + os.path.exists(os.path.join(staged_root, "data", ".trash")) + ) self.assertEqual(len(commands), 2) for command, kwargs in commands: @@ -405,6 +420,8 @@ class BackendProgressCommandTests(unittest.TestCase): self.assertIn("--log-level", command) self.assertIn("INFO", command) self.assertIn("--config", command) + self.assertIn("--exclude", command) + self.assertIn("/.trash/**", command) self.assertEqual( run_streaming.call_args.kwargs["progress"].backend_name, "rclone" ) @@ -430,6 +447,7 @@ class BackendProgressCommandTests(unittest.TestCase): self.assertIn("--mkpath", command) self.assertIn("--progress", command) self.assertIn("--outbuf=L", command) + self.assertIn("--exclude=/.trash/", command) self.assertEqual( run_streaming.call_args.kwargs["progress"].backend_name, "rsync" ) diff --git a/tests/test_trash.py b/tests/test_trash.py index 34d5d91..16b62e5 100644 --- a/tests/test_trash.py +++ b/tests/test_trash.py @@ -142,6 +142,52 @@ class TrashTests(unittest.TestCase): self.assertTrue(os.path.isfile(recent)) self.assertEqual(trash.list_items(now=now)["matched"], 1) + def test_temporary_and_document_lock_files_are_never_exposed_or_retained(self): + with tempfile.TemporaryDirectory() as tmpdir: + env = self.roots(tmpdir) + with mock.patch.dict(os.environ, env): + trash.ensure_trash_roots() + temporary = self.recycled_file( + env["GROUP_ROOT"], "alice/Finance/713A292F.tmp", b"temp" + ) + versioned_temporary = self.recycled_file( + env["GROUP_ROOT"], + "alice/Finance/Copy #2 of 713A292F.TMP", + b"versioned temp", + ) + document_lock = self.recycled_file( + env["GROUP_ROOT"], + "alice/Finance/~$RG Eingang 2026.xlsx", + b"lock", + ) + versioned_lock = self.recycled_file( + env["GROUP_ROOT"], + "alice/Finance/Copy #3 of ~$RG Eingang 2026.xlsx", + b"versioned lock", + ) + + self.assertEqual(trash.list_items()["matched"], 0) + with self.assertRaises(FileNotFoundError): + trash.open_download( + trash.encode_item_id( + "Data", "alice/Finance/713A292F.tmp" + ) + ) + + result = trash.cleanup_expired() + + self.assertEqual( + result, + {"removed": 4, "removedBytes": 36}, + ) + for path in ( + temporary, + versioned_temporary, + document_lock, + versioned_lock, + ): + self.assertFalse(os.path.exists(path)) + def test_cleanup_removes_expired_symlinks_without_following_them(self): with tempfile.TemporaryDirectory() as tmpdir: env = self.roots(tmpdir) diff --git a/tests/test_web_ui.py b/tests/test_web_ui.py index 5ff84af..02b7f26 100644 --- a/tests/test_web_ui.py +++ b/tests/test_web_ui.py @@ -1332,19 +1332,57 @@ class WebPresentationTests(unittest.TestCase): share_config = config.split(f"[{share}]", 1)[1] if next_share: share_config = share_config.split(f"[{next_share}]", 1)[0] - self.assertIn("vfs objects = acl_xattr recycle full_audit", share_config) + # Audit must wrap recycle so an SMB deletion remains unlinkat in the + # activity log instead of becoming the recycle module's renameat. + self.assertIn("vfs objects = acl_xattr full_audit recycle", share_config) self.assertIn("recycle:repository = .trash/%U", share_config) self.assertIn("recycle:keeptree = yes", share_config) self.assertIn("recycle:versions = yes", share_config) self.assertIn("recycle:touch_mtime = yes", share_config) + self.assertIn("recycle:exclude = *.tmp,*.TMP,~$*", share_config) self.assertIn("recycle:exclude_dir = .trash", share_config) self.assertIn("veto files = /.trash/", share_config) - self.assertIn("acl_xattr recycle full_audit", init_script) + for module in ("acl_xattr", "recycle", "full_audit"): + self.assertIn(module, init_script) self.assertIn("/app/trash.py --cleanup", init_script) self.assertIn("TRASH_RETENTION_DAYS", init_script) self.assertIn("COPY app/trash.py /app/trash.py", dockerfile) + def test_application_has_no_favicon(self): + root = os.path.join(os.path.dirname(__file__), "..") + web_root = os.path.join(root, "app", "web") + with open(os.path.join(web_root, "index.html"), encoding="utf-8") as handle: + index = handle.read() + with open(os.path.join(root, "app", "web_ui.py"), encoding="utf-8") as handle: + server = handle.read() + + self.assertNotIn("favicon", index.casefold()) + self.assertNotIn("favicon", server.casefold()) + self.assertFalse(os.path.exists(os.path.join(web_root, "favicon.svg"))) + + def test_disposable_dc_is_compatible_with_rootless_podman(self): + root = os.path.join(os.path.dirname(__file__), "..") + with open( + os.path.join(root, "dev", "ad-dc.Dockerfile"), + encoding="utf-8", + ) as handle: + dockerfile = handle.read() + with open( + os.path.join(root, "dev", "ad-entrypoint.sh"), + encoding="utf-8", + ) as handle: + entrypoint = handle.read() + with open(os.path.join(root, "scripts", "dev"), encoding="utf-8") as handle: + launcher = handle.read() + + self.assertIn("lowerBound: 10000", dockerfile) + self.assertIn("upperBound: 60000", dockerfile) + self.assertIn("xattr_tdb:file = /var/lib/samba/state/xattr.tdb", entrypoint) + self.assertIn("samba-tool ntacl sysvolcheck", entrypoint) + self.assertNotIn("--use-rfc2307", entrypoint) + self.assertNotIn("--cap-add SYS_ADMIN", launcher) + class TlsSummaryTests(unittest.TestCase): @mock.patch("app.web_ui.ssl._ssl._test_decode_cert")