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"))
+ }
+}