diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b18936 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Allowlist: only what the Gradle build needs enters the build context, so +# local scan reports (noir-out/) and tool caches can never end up in a layer. +* +!build.gradle.kts +!settings.gradle.kts +!gradle.properties +!gradlew +!gradle/ +!core/ +!cli/ +!LICENSE + +**/build/ +**/.gradle/ +**/.kotlin/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 46f1477..879f043 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,9 @@ updates: schedule: interval: weekly open-pull-requests-limit: 5 + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc87d9c..cd01ab4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,3 +78,55 @@ jobs: if: always() shell: bash run: ./gradlew --stop || true + + docker: + name: docker (${{ matrix.arch }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: amd64 + - os: ubuntu-24.04-arm + arch: arm64 + + steps: + - uses: actions/checkout@v7 + + # .dockerignore is an allowlist: a local scan report must not reach a layer. + - name: Plant a report the build context must ignore + shell: bash + run: | + mkdir -p noir-out + echo "private scan data" > noir-out/report.json + + - name: Build image + shell: bash + run: docker build -t noir:ci . + + - name: Smoke test + shell: bash + run: | + set -euo pipefail + docker run --rm noir:ci --version + docker run --rm --entrypoint adb noir:ci version + # No phone here, so doctor exits 1. It must still find the packaged + # adb, notice the container and explain USB passthrough. + docker run --rm -e NOIR_LANG=en noir:ci doctor | tee doctor.txt || true + grep -q "container docker" doctor.txt + grep -q "adb /usr/bin/adb" doctor.txt + grep -q "no device detected in the container" doctor.txt + + - name: Build context holds the allowlist only + shell: bash + run: | + set -euo pipefail + docker build --target build -t noir:ci-build . + allowed='build\.gradle\.kts|settings\.gradle\.kts|gradle\.properties|gradlew|gradle|core|cli|LICENSE|build|\.gradle|\.kotlin' + extra="$(docker run --rm --entrypoint ls noir:ci-build -A /src | grep -vxE "$allowed" || true)" + if [ -n "$extra" ]; then + echo "unexpected files in the build context:" + echo "$extra" + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index da6c320..919cba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Docker detection** — `noir doctor` now reports whether Docker is installed + and whether its daemon is running, and prints the fix when the daemon is + stopped or refuses access. Any other failure, such as a rejected TLS + certificate, is shown as unknown with Docker's own message. Docker stays + optional: the row never changes the exit code, and the check only runs the + local `docker --version` and `docker version`. +- **container awareness** — NOIR recognises when it runs inside Docker, Podman, + Kubernetes or LXC. `doctor` shows a `container` row, and the "no device" + guidance explains USB passthrough instead of blaming the cable. +- **Docker image** — a `Dockerfile` builds an image with a JRE, adb and NOIR, + so a Linux host needs nothing but Docker. The README documents the USB + passthrough flags and the volume that keeps the adb authorisation. Docker + Desktop cannot pass USB through, so macOS and Windows stay with the + portable build. + ### Fixed - **dumpsys compatibility** — package parsing now tolerates OEM indentation diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f948d7e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# NOIR in a container: a JRE, adb and NOIR itself, so a Linux host needs +# nothing but Docker. +# +# docker build -t noir . +# docker run --rm -it \ +# --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb \ +# -v noir-home:/root -v "$PWD:/work" noir +# +# README ("方式三:Docker") explains every flag. + +# Build and runtime share one Java release: Gradle targets the JDK it runs on, +# so a newer build JDK would produce classes the runtime cannot load. +# Both base images are pinned by version and digest; Dependabot moves the pins. +FROM eclipse-temurin:17.0.21_9-jdk-noble@sha256:1bedb04434bd275ef072355e9caec2a22be5240fbe82ad51a59323f2bbcab4a6 AS build +WORKDIR /src +COPY . . +RUN ./gradlew :cli:installDist --no-daemon --console=plain + +FROM eclipse-temurin:17.0.21_9-jre-noble@sha256:042c05a77eb915684fc8c92cfbbef4fd6f8c24ca7d934d1a7db3543453aaa077 +LABEL org.opencontainers.image.title="NOIR" \ + org.opencontainers.image.description="Android leak audit" \ + org.opencontainers.image.licenses="MIT" + +# adb comes from the distribution: Google's platform-tools zip is x86_64 only, +# the package also exists for arm64. jadx is not baked in; `noir setup` +# downloads it into the /root volume when deep/deepscan are wanted. +RUN apt-get update \ + && apt-get install -y --no-install-recommends adb \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /src/cli/build/install/noir /opt/noir +COPY LICENSE /opt/noir/LICENSE +ENV PATH="/opt/noir/bin:${PATH}" + +# The container stays root on purpose: adb has to open the USB device nodes +# passed through from the host, and those belong to root. With the documented +# flags that means every USB device on the host, not only the phone, so run +# only an image you built yourself. +# +# Reports land in ./noir-out relative to the working directory; mount it. +WORKDIR /work +ENTRYPOINT ["noir"] diff --git a/README.md b/README.md index abc37b2..5fb983a 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ noir doctor NOIR doctor platform Mac OS X aarch64 java 17.0.19 + docker /usr/local/bin/docker + 客户端 29.9.2 · 守护进程 29.9.2 运行中 adb /opt/homebrew/.../platform-tools/adb Version 37.0.1-15733141 device 178b0d2f device 2509FPN0BC @@ -89,6 +91,24 @@ noir setup # 缺什么下什么 noir setup --yes # 不询问,适合脚本 ``` +### Docker 安装 / 运行检测 + +`docker` 一行回答两个问题:Docker 装了没有,守护进程在不在跑。Docker 是可选项 +(只有想在容器里运行 NOIR 才需要),所以这一行只报告,不影响 `doctor` 的退出码: + +| 情况 | `doctor` 显示 | +|---|---| +| 没装 | `未安装(可选:…)` | +| 装了,守护进程在跑 | `客户端 29.9.2 · 守护进程 29.9.2 运行中` | +| 装了,没启动 | `守护进程未运行`,下一行是启动方法 | +| 装了,当前用户没权限 | `守护进程拒绝访问`,下一行是解决方法 | +| 其他错误(例如 TLS 证书校验失败),或 10 秒内没有应答 | `守护进程状态未知`,下一行是原因:Docker 的原始报错,或超时提示 | + +检测只调用本机的 `docker --version` 和 `docker version`,不拉镜像,也不启动容器。 + +NOIR 自己跑在容器里时(Docker / Podman / Kubernetes / LXC),`doctor` 会多一行 +`container docker`。这时找不到设备,提示会换成 USB 透传的做法,而不是让你换数据线。 + ## 出现问题时 所有错误都会带「怎么办」,而不是抛异常栈: @@ -180,6 +200,42 @@ cd NOIR ./cli/build/install/noir/bin/noir --about ``` +**方式三:Docker(Linux 宿主机)** + +镜像里带 JRE、adb 和 NOIR 本身,宿主机只需要 Docker。在仓库根目录构建 +(首次会下载 Gradle 和依赖,需要几分钟): + +```bash +docker build -t noir . +``` + +```bash +docker run --rm -it \ + --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb \ + -v noir-home:/root -v "$PWD:/work" \ + noir +``` + +| 参数 | 作用 | +|---|---| +| `--device-cgroup-rule='c 189:* rmw'`
`-v /dev/bus/usb:/dev/bus/usb` | 把 USB 设备透传进容器,重新插拔也有效 | +| `-v noir-home:/root` | 保存 adb 授权密钥和扫描缓存;不加的话,每次运行手机都要重新点「允许」 | +| `-v "$PWD:/work"` | 报告写到当前目录的 `noir-out/` | + +- 宿主机上如果也装了 adb,先执行 `adb kill-server`,否则它会占住手机。 +- 命令末尾可以接任何子命令,例如 `… noir doctor`、`… noir ui`。 +- `deep` / `deepscan` 需要的 jadx 不在镜像里:执行一次 `… noir setup --yes`,它会下载到 `noir-home`。 +- 在容器里找不到设备时,NOIR 会直接提示上面的透传参数。 + +> **权限说明**:容器内的 NOIR 和 adb 以 root 运行,这样才能打开透传进来的 USB 设备节点。 +> 上面两个 USB 参数给出的是宿主机上**全部** USB 设备的访问权,不只是手机,所以只运行你自己构建的镜像。 +> 副作用是 `noir-out/` 里的文件属主也是 root。 + +macOS / Windows 的 Docker Desktop 不能把 USB 设备透传进容器,这两个系统请用方式一的便携包。 + +不要改成让容器去连宿主机的 adb server:它默认只监听 `127.0.0.1:5037`,容器通常连不上; +而用 `adb -a` 让它监听所有网卡,会把一个没有认证的端口暴露给整个局域网,任何人都能借它控制手机。 + ## 构建 ```bash diff --git a/cli/src/main/kotlin/app/noir/cli/Guidance.kt b/cli/src/main/kotlin/app/noir/cli/Guidance.kt index e673c93..d9ba24a 100644 --- a/cli/src/main/kotlin/app/noir/cli/Guidance.kt +++ b/cli/src/main/kotlin/app/noir/cli/Guidance.kt @@ -7,6 +7,10 @@ package app.noir.cli import app.noir.core.Lang import app.noir.core.Tui +import app.noir.core.platform.ContainerRuntime +import app.noir.core.platform.DockerDaemon +import app.noir.core.platform.DockerStatus +import app.noir.core.platform.Platform /** Human-friendly text blocks: every failure should end with an action. */ object Guidance { @@ -171,6 +175,113 @@ object Guidance { """.trimMargin() }) + /** + * Doctor rows for Docker. Docker is optional, so "not installed" is a plain + * fact; a daemon that is installed but unusable still ends with an action. + */ + fun docker( + status: DockerStatus, + lang: Lang = LANG, + windows: Boolean = Platform.isWindows, + mac: Boolean = Platform.isMac + ): String { + fun l(zh: String, en: String) = if (lang == Lang.EN) en else zh + + if (!status.installed) { + return l( + " docker 未安装(可选:只有想在容器里运行 NOIR 才需要)", + " docker not installed (optional: only needed to run NOIR in a container)" + ) + } + + val client = status.clientVersion?.let { l("客户端 $it", "client $it") } + val daemon = when (status.daemon) { + DockerDaemon.RUNNING -> + status.serverVersion?.let { l("守护进程 $it 运行中", "daemon $it running") } + ?: l("守护进程运行中", "daemon running") + DockerDaemon.STOPPED -> l("守护进程未运行", "daemon not running") + DockerDaemon.DENIED -> l("守护进程拒绝访问", "daemon refused access") + DockerDaemon.UNKNOWN -> l("守护进程状态未知", "daemon state unknown") + } + val next = when (status.daemon) { + DockerDaemon.RUNNING -> null + DockerDaemon.STOPPED -> + if (windows || mac) { + l("启动方法:打开 Docker Desktop", "start it: open Docker Desktop") + } else { + l("启动方法:sudo systemctl start docker", "start it: sudo systemctl start docker") + } + DockerDaemon.DENIED -> when { + windows -> l( + "解决方法:把当前账号加入 docker-users 用户组,然后重新登录", + "fix: add your account to the docker-users group, then sign in again" + ) + mac -> l("解决方法:重启 Docker Desktop", "fix: restart Docker Desktop") + else -> l( + "解决方法:sudo usermod -aG docker \$USER,然后重新登录", + "fix: sudo usermod -aG docker \$USER, then log in again" + ) + } + DockerDaemon.UNKNOWN -> status.detail + } + + val indent = " ".repeat(12) + val sb = StringBuilder(" docker ").append(status.cli) + sb.append('\n').append(indent).append(asciiText(listOfNotNull(client, daemon).joinToString(" · "))) + if (next != null) sb.append('\n').append(indent).append(asciiText(next)) + return sb.toString() + } + + /** Doctor row shown only when NOIR itself runs inside a container. */ + fun container(runtime: ContainerRuntime, lang: Lang = LANG): String { + val name = if (runtime == ContainerRuntime.OTHER) { + if (lang == Lang.EN) "yes" else "是" + } else { + runtime.label + } + return " container $name" + } + + /** + * "No device" inside a container: the cable is rarely the problem there, + * the missing USB passthrough is. + */ + fun containerDeviceGuide(runtime: ContainerRuntime, lang: Lang = LANG): String { + val known = runtime != ContainerRuntime.OTHER + val zh = listOf( + "", + "NOIR 正在容器里运行" + (if (known) "(${runtime.label})" else "") + + ",容器默认看不到宿主机的 USB 设备。", + "", + "1. Linux 宿主机:docker run 时加上这两个参数,把 USB 透传进容器", + " --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb", + " 宿主机上的 adb 会占住手机,先执行 adb kill-server", + "", + "2. macOS / Windows:Docker Desktop 不能透传 USB", + " 请改用便携包,直接在宿主机上运行 NOIR", + "", + "3. 手机端仍需开启 USB 调试,并在弹窗里点「允许」" + ) + val en = listOf( + "", + "NOIR is running inside a container" + (if (known) " (${runtime.label})" else "") + + ". Containers cannot see the host's USB devices by default.", + "", + "1. Linux host: add these two flags to docker run to pass USB through", + " --device-cgroup-rule='c 189:* rmw' -v /dev/bus/usb:/dev/bus/usb", + " A host adb server holds the phone; stop it first: adb kill-server", + "", + "2. macOS / Windows: Docker Desktop cannot pass USB through", + " Use the portable build and run NOIR on the host instead", + "", + "3. The phone still needs USB debugging on, and its prompt allowed." + ) + return guideBox( + if (lang == Lang.EN) "no device detected in the container" else "容器里没有检测到设备", + if (lang == Lang.EN) en else zh + ) + } + fun error(title: String, hint: String? = null): String { val total = (Tui.terminalColumns() - 2).coerceIn(40, 100) val inner = total - 4 diff --git a/cli/src/main/kotlin/app/noir/cli/Main.kt b/cli/src/main/kotlin/app/noir/cli/Main.kt index b851f52..ba3cceb 100644 --- a/cli/src/main/kotlin/app/noir/cli/Main.kt +++ b/cli/src/main/kotlin/app/noir/cli/Main.kt @@ -29,6 +29,8 @@ import app.noir.core.apk.ManifestAnalyzer import app.noir.core.apk.ManifestFacts import app.noir.core.apk.Trackers import app.noir.core.jadx.Jadx +import app.noir.core.platform.ContainerRuntime +import app.noir.core.platform.Docker import app.noir.core.platform.Platform import app.noir.core.platform.Setup import java.io.File @@ -209,7 +211,7 @@ private fun usage() { noir guided setup + scan (interactive) noir --about what this is, what it writes, license noir --version print the version - noir doctor check adb / jadx / device + noir doctor check adb / jadx / docker / device noir setup download missing tools into ~/.noir/tools noir devices list connected devices noir scan [options] inventory apps, permissions and endpoints @@ -366,7 +368,7 @@ private fun cmdDevices(raw: List) { val filter = args.value("serial") val devices = adb.devices().let { all -> if (filter == null) all else all.filter { it.serial == filter } } if (devices.isEmpty()) { - println(Guidance.deviceGuide()) + println(noDeviceGuide()) return } devices.firstOrNull { it.state == "device" }?.let { applyDeviceLanguage(adb, it) } @@ -726,6 +728,12 @@ private fun cmdDoctor(raw: List) { println(" platform ${Platform.describe()}") println(" java ${System.getProperty("java.version")}") + // Docker is optional: report it before anything that can end the check, never fail on it. + CONTAINER?.let { println(Guidance.container(it)) } + val docker = Docker.probe() + // Inside a container the Docker CLI is normally absent; "not installed" would only confuse. + if (docker.installed || CONTAINER == null) println(Guidance.docker(docker)) + val adbPath = Platform.findAdb(args.value("adb")) if (adbPath == null) { println(t(" adb 未找到", " adb not found")) @@ -748,7 +756,7 @@ private fun cmdDoctor(raw: List) { if (devices.isEmpty()) { println(t(" device 未连接", " device not connected")) println() - println(Guidance.deviceGuide()) + println(noDeviceGuide()) exitProcess(1) } else { devices.forEach { println(" device ${it.serial} ${it.state} ${it.model}") } @@ -1629,6 +1637,13 @@ private fun offerCleanup(args: Args, adb: Adb, scan: ScanResult, findings: List< private fun isInteractive(): Boolean = System.console() != null || System.getenv("NOIR_INTERACTIVE") == "1" +/** The container runtime NOIR itself runs in; null on an ordinary host. */ +private val CONTAINER: ContainerRuntime? by lazy { Docker.container() } + +/** Inside a container a missing device is usually missing USB passthrough, not a bad cable. */ +private fun noDeviceGuide(): String = + CONTAINER?.let { Guidance.containerDeviceGuide(it) } ?: Guidance.deviceGuide() + private fun connectAdb(args: Args): Adb { val explicit = args.value("adb") if (explicit != null && !File(explicit).exists()) { @@ -1695,7 +1710,7 @@ private fun awaitDevice(adb: Adb, requested: String?, interactive: Boolean): Dev val unauthorized = devices.any { it.state == "unauthorized" } if (!printed) { - println(if (unauthorized) Guidance.unauthorizedGuide() else Guidance.deviceGuide()) + println(if (unauthorized) Guidance.unauthorizedGuide() else noDeviceGuide()) printed = true } @@ -1715,7 +1730,7 @@ private fun awaitDevice(adb: Adb, requested: String?, interactive: Boolean): Dev if (waited >= 180) { throw NoirException( t("等了 180 秒仍然没有设备。", "no device appeared after 180 seconds."), - Guidance.deviceGuide() + noDeviceGuide() ) } diff --git a/cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt b/cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt new file mode 100644 index 0000000..802da8b --- /dev/null +++ b/cli/src/test/kotlin/app/noir/cli/DockerGuidanceTest.kt @@ -0,0 +1,142 @@ +/* + * NOIR — Android leak audit + * Copyright (c) 2026 Verlintas + * SPDX-License-Identifier: MIT + */ +package app.noir.cli + +import app.noir.core.Lang +import app.noir.core.Tui +import app.noir.core.platform.ContainerRuntime +import app.noir.core.platform.DockerDaemon +import app.noir.core.platform.DockerStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DockerGuidanceTest { + + private val han = Regex("[\\u4e00-\\u9fff]") + + private fun status(daemon: DockerDaemon, server: String? = null, detail: String? = null) = + DockerStatus("/usr/bin/docker", "29.9.2", daemon, server, detail) + + private val everyState = listOf( + DockerStatus(cli = null), + status(DockerDaemon.RUNNING, server = "29.9.2"), + status(DockerDaemon.STOPPED), + status(DockerDaemon.DENIED), + status(DockerDaemon.UNKNOWN, detail = "no answer within 10s") + ) + + @Test + fun `missing docker is reported as optional, not as a failure`() { + val row = Guidance.docker(DockerStatus(cli = null), Lang.EN) + assertEquals(1, row.lines().size) + assertTrue(row.contains("not installed")) + assertTrue(row.contains("optional")) + } + + @Test + fun `a running daemon shows both versions and no advice`() { + val rows = Guidance.docker(status(DockerDaemon.RUNNING, server = "29.9.1"), Lang.EN).lines() + assertEquals(listOf(" docker /usr/bin/docker", " client 29.9.2 · daemon 29.9.1 running"), rows) + } + + @Test + fun `a stopped daemon ends with how to start it on this platform`() { + fun advice(windows: Boolean, mac: Boolean) = + Guidance.docker(status(DockerDaemon.STOPPED), Lang.EN, windows = windows, mac = mac).lines().last() + + assertTrue(advice(windows = false, mac = false).contains("systemctl start docker")) + assertTrue(advice(windows = true, mac = false).contains("Docker Desktop")) + assertTrue(advice(windows = false, mac = true).contains("Docker Desktop")) + } + + @Test + fun `a refused daemon ends with how to get access on this platform`() { + fun advice(windows: Boolean, mac: Boolean) = + Guidance.docker(status(DockerDaemon.DENIED), Lang.EN, windows = windows, mac = mac).lines().last() + + assertTrue(advice(windows = false, mac = false).contains("usermod -aG docker \$USER")) + assertTrue(advice(windows = true, mac = false).contains("docker-users")) + assertTrue(advice(windows = false, mac = true).contains("Docker Desktop")) + } + + @Test + fun `an unknown state passes the cli's own reason through`() { + val rows = Guidance.docker(status(DockerDaemon.UNKNOWN, detail = "no answer within 10s"), Lang.EN).lines() + assertTrue(rows[1].contains("daemon state unknown")) + assertEquals(" no answer within 10s", rows.last()) + } + + @Test + fun `english docker text has no chinese characters`() { + val blocks = everyState.flatMap { state -> + listOf( + Guidance.docker(state, Lang.EN, windows = false, mac = false), + Guidance.docker(state, Lang.EN, windows = true, mac = false), + Guidance.docker(state, Lang.EN, windows = false, mac = true) + ) + } + ContainerRuntime.entries.flatMap { runtime -> + listOf(Guidance.container(runtime, Lang.EN), Guidance.containerDeviceGuide(runtime, Lang.EN)) + } + blocks.forEach { block -> + assertFalse(han.containsMatchIn(block), "untranslated guidance:\n$block") + } + } + + @Test + fun `ascii mode keeps english docker text 7-bit`() { + ASCII = true + try { + val blocks = everyState.map { Guidance.docker(it, Lang.EN) } + + ContainerRuntime.entries.map { Guidance.containerDeviceGuide(it, Lang.EN) } + blocks.forEach { block -> + assertTrue(block.all { it.code < 128 }, "non-ASCII output in --ascii mode:\n$block") + } + } finally { + ASCII = false + } + } + + @Test + fun `container row names the runtime when it is known`() { + assertEquals(" container docker", Guidance.container(ContainerRuntime.DOCKER, Lang.EN)) + assertEquals(" container podman", Guidance.container(ContainerRuntime.PODMAN, Lang.ZH)) + assertEquals(" container yes", Guidance.container(ContainerRuntime.OTHER, Lang.EN)) + } + + @Test + fun `container guide explains usb passthrough and fits the terminal`() { + val limit = Tui.terminalColumns() + listOf(Lang.ZH, Lang.EN).forEach { lang -> + ContainerRuntime.entries.forEach { runtime -> + val guide = Guidance.containerDeviceGuide(runtime, lang) + assertTrue(guide.contains("/dev/bus/usb"), "no passthrough hint:\n$guide") + guide.lines().forEach { line -> + assertTrue(Tui.visibleWidth(line) <= limit, "line wider than $limit: [$line]") + } + } + } + assertTrue(Guidance.containerDeviceGuide(ContainerRuntime.DOCKER, Lang.EN).contains("(docker)")) + assertFalse(Guidance.containerDeviceGuide(ContainerRuntime.OTHER, Lang.EN).contains("(")) + } + + @Test + fun `container guide sends docker desktop users to usb, not to a host adb server`() { + // A host adb server listens on 127.0.0.1 only, and opening it up with + // `adb -a` exposes an unauthenticated port. The guide must not suggest either. + listOf(Lang.ZH, Lang.EN).forEach { lang -> + ContainerRuntime.entries.forEach { runtime -> + val guide = Guidance.containerDeviceGuide(runtime, lang) + listOf("ADB_SERVER_SOCKET", "host.docker.internal", "5037", "adb -a").forEach { risky -> + assertFalse(guide.contains(risky), "guide points at a host adb server ($risky):\n$guide") + } + } + } + assertTrue(Guidance.containerDeviceGuide(ContainerRuntime.DOCKER, Lang.EN).contains("portable build")) + assertTrue(Guidance.containerDeviceGuide(ContainerRuntime.DOCKER, Lang.ZH).contains("便携包")) + } +} diff --git a/core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt b/core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt new file mode 100644 index 0000000..895ee1e --- /dev/null +++ b/core/src/jvmMain/kotlin/app/noir/core/platform/Docker.kt @@ -0,0 +1,295 @@ +/* + * NOIR — Android leak audit + * Copyright (c) 2026 Verlintas + * SPDX-License-Identifier: MIT + */ +package app.noir.core.platform + +import java.io.File +import java.util.concurrent.TimeUnit + +/** How the Docker daemon answered when it was asked for its version. */ +enum class DockerDaemon { + /** The daemon answered. */ + RUNNING, + + /** The CLI is installed, but no daemon is listening. */ + STOPPED, + + /** A daemon is listening, but this user is not allowed to talk to it. */ + DENIED, + + /** No clear answer: a timeout, or an error NOIR does not recognise. */ + UNKNOWN +} + +/** + * Result of [Docker.probe]. `cli == null` means Docker is not installed; the + * other fields only carry meaning when it is. + */ +data class DockerStatus( + val cli: String?, + val clientVersion: String? = null, + val daemon: DockerDaemon = DockerDaemon.UNKNOWN, + val serverVersion: String? = null, + /** First useful line of the CLI's complaint, when there was one. */ + val detail: String? = null +) { + val installed: Boolean get() = cli != null + val running: Boolean get() = daemon == DockerDaemon.RUNNING +} + +/** The container runtime NOIR itself is running under. */ +enum class ContainerRuntime(val label: String) { + DOCKER("docker"), + PODMAN("podman"), + KUBERNETES("kubernetes"), + LXC("lxc"), + OTHER("container") +} + +/** What [Docker.parseDaemon] read out of `docker version`. */ +internal data class DaemonAnswer( + val state: DockerDaemon, + val version: String? = null, + val detail: String? = null +) + +/** + * Docker is optional for NOIR — it is only needed to run NOIR from a container + * image. This answers three questions for `noir doctor`: is Docker installed, + * is its daemon running, and is NOIR itself inside a container (where USB + * devices stay invisible until they are passed through). + * + * Only the local `docker` CLI is asked, against whatever endpoint the user + * already configured for it. Nothing is pulled and no container is started. + */ +object Docker { + + private val CLIENT_VERSION = Regex("\\bversion\\s+v?([0-9][^\\s,]*)", RegexOption.IGNORE_CASE) + private val VERSION_LINE = Regex("^v?[0-9]+\\.[0-9]\\S*$") + + // Sentences the Docker CLI writes itself, and only when it could not reach + // the daemon's socket or pipe at all. "error during connect" is not one of + // them: the CLI puts that in front of every transport failure, a rejected + // TLS certificate included, and those leave the daemon's state open. + private val STOPPED_PHRASES = listOf( + "cannot connect to the docker daemon", + "failed to connect to the docker api", + "docker daemon is not running" + ) + + private val DENIED_PHRASES = listOf( + "permission denied while trying to connect" + ) + + // The operating system's own wording also turns up for unrelated files (a + // missing ca.pem, an unreadable key), so it only counts on a line that + // names the daemon's endpoint. Windows writes it in the system language. + private val ENDPOINT = listOf("dial unix", "open //./pipe/") + + private val OS_MISSING = listOf( + "no such file or directory", + "connection refused", + "cannot find the file specified", + "系统找不到指定的文件" + ) + + private val OS_DENIED = listOf( + "permission denied", + "access is denied", + "拒绝访问" + ) + + /** Locates the Docker CLI: `PATH` first, then the usual install locations. */ + fun find(): String? { + Platform.which("docker")?.let { return it } + + val home = System.getProperty("user.home") + val candidates = buildList { + when { + Platform.isWindows -> { + val programs = System.getenv("ProgramFiles") ?: "C:\\Program Files" + add("$programs\\Docker\\Docker\\resources\\bin\\docker.exe") + } + Platform.isMac -> { + add("/usr/local/bin/docker") + add("/opt/homebrew/bin/docker") + add("$home/.docker/bin/docker") + add("/Applications/Docker.app/Contents/Resources/bin/docker") + add("$home/.orbstack/bin/docker") + add("$home/.rd/bin/docker") + } + else -> { + add("/usr/bin/docker") + add("/usr/local/bin/docker") + add("/snap/bin/docker") + add("$home/bin/docker") + add("$home/.rd/bin/docker") + } + } + } + return candidates.firstOrNull(Platform::usable) + } + + /** + * Asks the CLI for its own version, then for the daemon's. + * + * Never throws and never waits longer than [timeoutSec] per question: a + * CLI that cannot start, or a daemon endpoint that does not answer, is + * reported as [DockerDaemon.UNKNOWN] with the reason in `detail`. + */ + fun probe(cli: String? = find(), timeoutSec: Long = 10): DockerStatus { + if (cli == null) return DockerStatus(cli = null) + val silent = "no answer within ${timeoutSec}s" + + // `--version` never touches the daemon, so it works while Docker is stopped. + val client = try { + run(cli, listOf("--version"), timeoutSec) + } catch (e: Exception) { + return DockerStatus(cli, detail = e.message ?: e::class.simpleName) + } + if (client.timedOut) return DockerStatus(cli, detail = silent) + val clientVersion = parseClientVersion(client.text) + + val server = try { + run(cli, listOf("version", "--format", "{{.Server.Version}}"), timeoutSec) + } catch (e: Exception) { + return DockerStatus(cli, clientVersion, detail = e.message ?: e::class.simpleName) + } + if (server.timedOut) return DockerStatus(cli, clientVersion, detail = silent) + + val answer = parseDaemon(server.code, server.text) + return DockerStatus(cli, clientVersion, answer.state, answer.version, answer.detail) + } + + /** `Docker version 29.9.2, build a82e528` → `29.9.2`. */ + internal fun parseClientVersion(output: String): String? = + CLIENT_VERSION.find(output)?.groupValues?.get(1) + + /** + * Reads the outcome of `docker version --format {{.Server.Version}}`, with + * stderr merged in. A running daemon prints just its version; otherwise + * the CLI explains why it could not reach one, in wording that differs by + * platform and Docker release. + */ + internal fun parseDaemon(code: Int, output: String): DaemonAnswer { + val lines = output.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList() + if (code == 0) { + // Warnings may precede the answer; the version is the last thing printed. + lines.lastOrNull { VERSION_LINE.matches(it) }?.let { + return DaemonAnswer(DockerDaemon.RUNNING, version = it.removePrefix("v")) + } + } + + // A warning (an unreadable config file, …) is not the reason the daemon is silent. + val complaints = lines.filterNot { it.startsWith("warning", ignoreCase = true) } + + // Refused is checked first: that message also says the daemon could not be reached. + complaints.firstOrNull(::refused)?.let { + return DaemonAnswer(DockerDaemon.DENIED, detail = it.take(300)) + } + complaints.firstOrNull(::unreachable)?.let { + return DaemonAnswer(DockerDaemon.STOPPED, detail = it.take(300)) + } + return DaemonAnswer( + DockerDaemon.UNKNOWN, + detail = (complaints.firstOrNull() ?: lines.firstOrNull())?.take(300) + ) + } + + private fun refused(line: String): Boolean { + val text = line.lowercase() + return DENIED_PHRASES.any { it in text } || + (ENDPOINT.any { it in text } && OS_DENIED.any { it in text }) + } + + private fun unreachable(line: String): Boolean { + val text = line.lowercase() + return STOPPED_PHRASES.any { it in text } || + (ENDPOINT.any { it in text } && OS_MISSING.any { it in text }) + } + + /** The container runtime NOIR is running in, or null on an ordinary host. */ + fun container(): ContainerRuntime? { + if (!Platform.isLinux) return null + return detectContainer( + env = System.getenv(), + exists = { File(it).exists() }, + cgroup = try { + File("/proc/1/cgroup").readText() + } catch (e: Exception) { + null + } + ) + } + + /** + * Container detection from the marker files and variables the runtimes + * leave behind. The cgroup of PID 1 is the fallback: on cgroup v1 it names + * the runtime, on pure v2 it is just `0::/` and says nothing. + */ + internal fun detectContainer( + env: Map, + exists: (String) -> Boolean, + cgroup: String? + ): ContainerRuntime? { + if (!env["KUBERNETES_SERVICE_HOST"].isNullOrBlank()) return ContainerRuntime.KUBERNETES + if (exists("/.dockerenv")) return ContainerRuntime.DOCKER + if (exists("/run/.containerenv")) return ContainerRuntime.PODMAN + + // systemd's convention: container=docker|podman|lxc|oci|… + env["container"]?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }?.let { value -> + return when { + "docker" in value -> ContainerRuntime.DOCKER + "podman" in value -> ContainerRuntime.PODMAN + "lxc" in value -> ContainerRuntime.LXC + else -> ContainerRuntime.OTHER + } + } + + val groups = cgroup?.lowercase() ?: return null + return when { + "kubepods" in groups -> ContainerRuntime.KUBERNETES + "docker" in groups -> ContainerRuntime.DOCKER + "libpod" in groups -> ContainerRuntime.PODMAN + "/lxc" in groups -> ContainerRuntime.LXC + else -> null + } + } + + private class Output(val code: Int, val text: String, val timedOut: Boolean) + + /** + * Runs the CLI with a real deadline. stdout is drained on a daemon thread + * so a process that hangs without closing its pipe cannot hang NOIR. + */ + private fun run(cli: String, args: List, timeoutSec: Long): Output { + val process = ProcessBuilder(Exec.argv(cli, args)) + .redirectErrorStream(true) + .start() + val text = StringBuilder() + val reader = Thread { + try { + process.inputStream.bufferedReader().use { stream -> + while (true) { + val line = stream.readLine() ?: break + synchronized(text) { text.append(line).append('\n') } + } + } + } catch (_: Exception) { + // Process killed on timeout, or the pipe was closed. + } + } + reader.isDaemon = true + reader.start() + + if (!process.waitFor(timeoutSec, TimeUnit.SECONDS)) { + process.destroyForcibly() + reader.join(2_000) + return Output(-1, synchronized(text) { text.toString() }, timedOut = true) + } + reader.join(2_000) + return Output(process.exitValue(), synchronized(text) { text.toString() }, timedOut = false) + } +} diff --git a/core/src/jvmMain/kotlin/app/noir/core/platform/Platform.kt b/core/src/jvmMain/kotlin/app/noir/core/platform/Platform.kt index 3ec4b9d..710c466 100644 --- a/core/src/jvmMain/kotlin/app/noir/core/platform/Platform.kt +++ b/core/src/jvmMain/kotlin/app/noir/core/platform/Platform.kt @@ -84,7 +84,7 @@ object Platform { return candidates.firstOrNull(::usable) } - private fun usable(path: String): Boolean = File(path).let { it.isFile && (isWindows || it.canExecute()) } + internal fun usable(path: String): Boolean = File(path).let { it.isFile && (isWindows || it.canExecute()) } /** Minimal `which`, without spawning a shell. */ fun which(command: String): String? { diff --git a/core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt b/core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt new file mode 100644 index 0000000..78bbe57 --- /dev/null +++ b/core/src/jvmTest/kotlin/app/noir/core/platform/DockerTest.kt @@ -0,0 +1,365 @@ +/* + * NOIR — Android leak audit + * Copyright (c) 2026 Verlintas + * SPDX-License-Identifier: MIT + */ +package app.noir.core.platform + +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DockerTest { + + /** A stand-in `docker` that answers `--version` and `version` the way [body] says. */ + private fun fakeDocker(body: String): String { + val file = File(Files.createTempDirectory("noir-docker").toFile(), "docker") + file.writeText("#!/bin/sh\n$body\n") + file.setExecutable(true) + return file.absolutePath + } + + @Test + fun `client version is read from docker --version`() { + assertEquals("29.9.2", Docker.parseClientVersion("Docker version 29.9.2, build a82e528\n")) + assertEquals("20.10.24+dfsg1", Docker.parseClientVersion("Docker version 20.10.24+dfsg1, build 297e128")) + assertEquals("4.9.3", Docker.parseClientVersion("podman version 4.9.3")) + assertNull(Docker.parseClientVersion("docker: command not found")) + } + + @Test + fun `a daemon that answers is running`() { + val answer = Docker.parseDaemon(0, "29.9.2\n") + assertEquals(DockerDaemon.RUNNING, answer.state) + assertEquals("29.9.2", answer.version) + } + + @Test + fun `warnings before the version do not hide a running daemon`() { + val answer = Docker.parseDaemon( + 0, + "WARNING: Error loading config file: /home/u/.docker/config.json: " + + "open /home/u/.docker/config.json: permission denied\n" + + "27.3.1\n" + ) + assertEquals(DockerDaemon.RUNNING, answer.state) + assertEquals("27.3.1", answer.version) + } + + @Test + fun `a missing or dead socket means the daemon is stopped`() { + // Docker 29 on Linux, socket file absent. + val modern = Docker.parseDaemon( + 1, + "\nfailed to connect to the docker API at unix:///var/run/docker.sock; check if the path " + + "is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: " + + "no such file or directory\n" + ) + assertEquals(DockerDaemon.STOPPED, modern.state) + assertNull(modern.version) + assertTrue(modern.detail!!.startsWith("failed to connect to the docker API")) + + // Socket file present, nobody listening; also what older releases always printed. + val classic = Docker.parseDaemon( + 1, + "\nCannot connect to the Docker daemon at unix:///var/run/docker.sock. " + + "Is the docker daemon running?\n" + ) + assertEquals(DockerDaemon.STOPPED, classic.state) + + // Docker Desktop on Windows, not started. + val windows = Docker.parseDaemon( + 1, + "error during connect: Get \"http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.47/version\": " + + "open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.\n" + ) + assertEquals(DockerDaemon.STOPPED, windows.state) + } + + @Test + fun `a refused socket is denied, not stopped`() { + val modern = Docker.parseDaemon( + 1, + "\npermission denied while trying to connect to the docker API at unix:///var/run/docker.sock\n" + ) + assertEquals(DockerDaemon.DENIED, modern.state) + + val classic = Docker.parseDaemon( + 1, + "permission denied while trying to connect to the Docker daemon socket at " + + "unix:///var/run/docker.sock: Get \"http://%2Fvar%2Frun%2Fdocker.sock/v1.24/version\": " + + "dial unix /var/run/docker.sock: connect: permission denied\n" + ) + assertEquals(DockerDaemon.DENIED, classic.state) + + val windows = Docker.parseDaemon( + 1, + "error during connect: Get \"http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.24/version\": " + + "open //./pipe/docker_engine: Access is denied.\n" + ) + assertEquals(DockerDaemon.DENIED, windows.state) + + // Same failure on a Chinese Windows: the OS half of the message is localised. + val windowsZh = Docker.parseDaemon( + 1, + "error during connect: Get \"http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.24/version\": " + + "open //./pipe/docker_engine: 拒绝访问。\n" + ) + assertEquals(DockerDaemon.DENIED, windowsZh.state) + } + + @Test + fun `windows blames privileges even when the pipe is simply missing`() { + // A non-elevated client gets this sentence whatever went wrong; only the + // operating system's own half says whether the pipe exists. + val prefix = "error during connect: in the default daemon configuration on Windows, the docker " + + "client must be run with elevated privileges to connect: " + + "Get \"http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.24/version\": open //./pipe/docker_engine: " + + assertEquals( + DockerDaemon.STOPPED, + Docker.parseDaemon(1, prefix + "The system cannot find the file specified.\n").state + ) + assertEquals(DockerDaemon.DENIED, Docker.parseDaemon(1, prefix + "Access is denied.\n").state) + + // Docker Desktop's pipe on a Chinese Windows. + val desktopZh = Docker.parseDaemon( + 1, + "error during connect: Get \"http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.47/version\": " + + "open //./pipe/dockerDesktopLinuxEngine: 系统找不到指定的文件。\n" + ) + assertEquals(DockerDaemon.STOPPED, desktopZh.state) + } + + @Test + fun `a tls failure is not a stopped daemon`() { + // All four were printed by Docker 29 against a port that was listening. + // "error during connect" is the CLI's prefix for every transport failure, + // so it says nothing about whether the daemon is up. + val untrusted = Docker.parseDaemon( + 1, + "\nerror during connect: Get \"https://127.0.0.1:2376/v1.56/version\": tls: failed to verify " + + "certificate: x509: certificate signed by unknown authority\n" + ) + assertEquals(DockerDaemon.UNKNOWN, untrusted.state) + assertTrue(untrusted.detail!!.contains("x509: certificate signed by unknown authority")) + + val plainToTls = Docker.parseDaemon( + 1, + "\nerror during connect: Get \"http://127.0.0.1:2376/v1.56/version\": read tcp " + + "127.0.0.1:46398->127.0.0.1:2376: read: connection reset by peer\n" + ) + assertEquals(DockerDaemon.UNKNOWN, plainToTls.state) + + val tlsToPlain = Docker.parseDaemon( + 1, + "\nerror during connect: Get \"https://127.0.0.1:2375/v1.56/version\": http: server gave HTTP " + + "response to HTTPS client\n" + ) + assertEquals(DockerDaemon.UNKNOWN, tlsToPlain.state) + + // An SSH endpoint that refuses the connection is not a stopped daemon either. + val ssh = Docker.parseDaemon( + 1, + "error during connect: Get \"http://docker.example.com/v1.56/version\": command [ssh -o " + + "ConnectTimeout=30 -T -- host docker system dial-stdio] has exited with exit status 255, " + + "make sure the URL is valid, and Docker 18.09 or later is installed on the remote host: " + + "stderr=ssh: connect to host host port 22: Connection refused\n" + ) + assertEquals(DockerDaemon.UNKNOWN, ssh.state) + } + + @Test + fun `operating system errors only count for the daemon's own endpoint`() { + // Docker 29 with DOCKER_TLS_VERIFY=1 and no certificates: a file is + // missing, but it is not the socket. + val missingCa = Docker.parseDaemon( + 1, + "Failed to initialize: unable to resolve docker endpoint: open /home/u/.docker/ca.pem: " + + "no such file or directory\n" + ) + assertEquals(DockerDaemon.UNKNOWN, missingCa.state) + assertTrue(missingCa.detail!!.contains("ca.pem")) + + val missingCaWindows = Docker.parseDaemon( + 1, + "Failed to initialize: unable to resolve docker endpoint: open C:\\Users\\u\\.docker\\ca.pem: " + + "The system cannot find the file specified.\n" + ) + assertEquals(DockerDaemon.UNKNOWN, missingCaWindows.state) + + // Likewise an unreadable key is not a refused socket. + val unreadableKey = Docker.parseDaemon( + 1, + "Failed to initialize: unable to resolve docker endpoint: open /home/u/.docker/key.pem: " + + "permission denied\n" + ) + assertEquals(DockerDaemon.UNKNOWN, unreadableKey.state) + } + + @Test + fun `an unreadable config file does not turn a stopped daemon into a denied one`() { + val answer = Docker.parseDaemon( + 1, + "WARNING: Error loading config file: /home/u/.docker/config.json: " + + "open /home/u/.docker/config.json: permission denied\n" + + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. " + + "Is the docker daemon running?\n" + ) + assertEquals(DockerDaemon.STOPPED, answer.state) + assertTrue(answer.detail!!.startsWith("Cannot connect")) + } + + @Test + fun `an unrecognised failure stays unknown and keeps its reason`() { + val answer = Docker.parseDaemon( + 125, + "Error: template: version:1:9: executing \"version\" at <.Server.Version>: " + + "nil pointer evaluating *define.Version.Version\n" + ) + assertEquals(DockerDaemon.UNKNOWN, answer.state) + assertTrue(answer.detail!!.startsWith("Error: template")) + + // Exit 0 without a version is not proof of a running daemon. + assertEquals(DockerDaemon.UNKNOWN, Docker.parseDaemon(0, "\n").state) + assertEquals(DockerDaemon.UNKNOWN, Docker.parseDaemon(0, "").state) + } + + @Test + fun `no cli means docker is not installed`() { + val status = Docker.probe(cli = null) + assertFalse(status.installed) + assertFalse(status.running) + } + + @Test + fun `probe reports a running daemon`() { + if (Platform.isWindows) return + val cli = fakeDocker( + """ + case "${'$'}1" in + --version) echo "Docker version 27.3.1, build ce12230" ;; + version) echo "27.3.1" ;; + esac + """.trimIndent() + ) + + val status = Docker.probe(cli) + assertTrue(status.installed) + assertTrue(status.running) + assertEquals(cli, status.cli) + assertEquals("27.3.1", status.clientVersion) + assertEquals("27.3.1", status.serverVersion) + } + + @Test + fun `probe reports an installed but stopped daemon`() { + if (Platform.isWindows) return + val cli = fakeDocker( + """ + case "${'$'}1" in + --version) echo "Docker version 27.3.1, build ce12230" ;; + version) + echo + echo "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" >&2 + exit 1 ;; + esac + """.trimIndent() + ) + + val status = Docker.probe(cli) + assertTrue(status.installed) + assertFalse(status.running) + assertEquals(DockerDaemon.STOPPED, status.daemon) + assertEquals("27.3.1", status.clientVersion) + assertNull(status.serverVersion) + } + + @Test + fun `probe gives up on a daemon that never answers`() { + if (Platform.isWindows) return + val cli = fakeDocker( + """ + case "${'$'}1" in + --version) echo "Docker version 27.3.1, build ce12230" ;; + version) sleep 8 ;; + esac + """.trimIndent() + ) + + val started = System.currentTimeMillis() + val status = Docker.probe(cli, timeoutSec = 1) + val elapsed = System.currentTimeMillis() - started + + assertEquals(DockerDaemon.UNKNOWN, status.daemon) + assertEquals("27.3.1", status.clientVersion) + assertEquals("no answer within 1s", status.detail) + // The orphaned `sleep` keeps the pipe open; probe must not wait for it. + assertTrue(elapsed < 6_000, "timeout took too long: ${elapsed}ms") + } + + @Test + fun `probe survives a cli that cannot start`() { + val missing = File(Files.createTempDirectory("noir-docker").toFile(), "docker").absolutePath + + val status = Docker.probe(missing) + assertTrue(status.installed) + assertEquals(DockerDaemon.UNKNOWN, status.daemon) + assertNotNull(status.detail) + } + + @Test + fun `marker files and variables identify the container runtime`() { + fun detect( + env: Map = emptyMap(), + files: Set = emptySet(), + cgroup: String? = null + ) = Docker.detectContainer(env, { it in files }, cgroup) + + assertEquals(ContainerRuntime.DOCKER, detect(files = setOf("/.dockerenv"))) + assertEquals(ContainerRuntime.PODMAN, detect(files = setOf("/run/.containerenv"))) + assertEquals(ContainerRuntime.PODMAN, detect(env = mapOf("container" to "podman"))) + assertEquals(ContainerRuntime.LXC, detect(env = mapOf("container" to "lxc"))) + assertEquals(ContainerRuntime.OTHER, detect(env = mapOf("container" to "oci"))) + + // A pod is still a pod when the node happens to run Docker underneath. + assertEquals( + ContainerRuntime.KUBERNETES, + detect(env = mapOf("KUBERNETES_SERVICE_HOST" to "10.96.0.1"), files = setOf("/.dockerenv")) + ) + } + + @Test + fun `cgroup v1 names the runtime when the marker files are gone`() { + val docker = """ + 9:name=systemd:/docker/5ee95099ef2841a219aeb64bc156a17cd3d894287bd14e04bb066940ee26373d + 5:devices:/docker/5ee95099ef2841a219aeb64bc156a17cd3d894287bd14e04bb066940ee26373d + 0::/docker/5ee95099ef2841a219aeb64bc156a17cd3d894287bd14e04bb066940ee26373d + """.trimIndent() + val pod = "11:memory:/kubepods/burstable/pod3c1f0d5e/0a1b2c3d4e5f\n" + val podman = "0::/machine.slice/libpod-0a1b2c3d4e5f.scope\n" + + fun detect(cgroup: String?) = Docker.detectContainer(emptyMap(), { false }, cgroup) + + assertEquals(ContainerRuntime.DOCKER, detect(docker)) + assertEquals(ContainerRuntime.KUBERNETES, detect(pod)) + assertEquals(ContainerRuntime.PODMAN, detect(podman)) + } + + @Test + fun `an ordinary host is not a container`() { + fun detect(cgroup: String?) = Docker.detectContainer(emptyMap(), { false }, cgroup) + + // systemd host on cgroup v1, on cgroup v2, and a host where /proc is unreadable. + assertNull(detect("9:name=systemd:/\n8:pids:/\n5:devices:/\n")) + assertNull(detect("0::/init.scope\n")) + assertNull(detect(null)) + // An empty `container` variable is not a claim. + assertNull(Docker.detectContainer(mapOf("container" to " "), { false }, "0::/\n")) + } +}