cmake可见性和AppArmor示例

This commit is contained in:
NoDistanceY 2026-09-03 10:39:21 +08:00
commit 4b4b933e3d
19 changed files with 661 additions and 0 deletions

1
AppArmor/demo1/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
!*.elf

207
AppArmor/demo1/README.md Normal file
View file

@ -0,0 +1,207 @@
# AppArmor 入门 Demodemo1技术说明
> 环境Ubuntu 24.04 x86_64阿里云 ECS内核 6.8.0-124-generic
> AppArmor parser 4.0.1profile ABI 4.0。
> 运行日期2026-09-03最终报告见同目录 `report.txt`
## 1. Demo 目标
用一个最小可复现的例子演示 AppArmorLinux 内核的强制访问控制 / MAC 模块)最核心的三个行为:
| # | 场景 | 预期 |
|---|------|------|
| 1 | 访问 profile 中**显式允许**的路径 | 放行 |
| 2 | 访问 profile 中 `deny` 显式禁止的路径 | **静默拒绝**EACCES无审计日志 |
| 3 | 访问 profile 中**完全没有提及**的路径 | 拒绝 + 内核记录 `DENIED` 审计日志 |
同时覆盖两种可执行体形态:
- **ELF 二进制**`demo_app.c` 编译出的 `demo_app`)—— profile 直接 attach 到二进制路径;
- **Shell 脚本**`demo_shell.sh`)—— AppArmor 不认识"脚本"attach 的是**解释器**转场transition发生在 exec `/bin/bash` 时。
## 2. 文件清单
```
demo1/
├── demo_app.c # C 测试程序3 个文件访问探针
├── demo_shell.sh # bash 测试脚本3 个同类探针
├── profile.elf # demo_app 的 AppArmor profile
├── profile.script # demo_shell.sh 的 AppArmor profile
├── run.sh # 一键运行脚本(需 root
└── report.txt # 运行产物:完整执行报告
```
## 3. 测试程序设计
`demo_app.c``demo_shell.sh` 是同一组探针的两种实现:
```c
/* 探针1: 白名单 —— profile 允许 rw应当成功 */
fopen("/tmp/demo_elf.log", "w");
/* 探针2: 显式 deny —— deny /etc/shadow r静默拒绝 */
fopen("/etc/shadow", "r");
/* 探针3: 未提及路径 —— 隐式默认拒绝,拒绝并记录 DENIED 日志 */
fopen("/tmp/demo_forbidden.log", "w");
```
三个探针分别命中 AppArmor 白名单规则的"允许"、`deny` 关键字、以及 enforce 模式下的默认拒绝default deny
## 4. Profile 逐行解读
### 4.1 `profile.elf`ELF 二进制)
```
abi <abi/4.0>,
/home/fnzhang/Project/examples/AppArmor/demo1/demo_app flags=(enforce) {
/home/fnzhang/Project/examples/AppArmor/demo1/demo_app r,
/lib/** rm,
/usr/lib/** rm,
/usr/lib64/** rm,
/etc/ld.so.cache r,
/tmp/demo_elf.log rw,
deny /etc/shadow r,
}
```
| 语法元素 | 含义 |
|----------|------|
| `abi <abi/4.0>,` | 声明 profile 所依据的规则 ABI 版本。不写会按 parser 默认(本机 4.0)并可能告警;显式写出可保证规则语义在不同 parser 版本间稳定 |
| `路径 { ... }` | **匿名 attach 写法**profile 的 attachment 点就是这段路径本身,进程 exec 该路径时自动套用此 profile无需 `px` 转场规则 |
| `flags=(enforce)` | 强制模式:违规即拒绝。对比 `flags=(complain)` 只记日志不拦截(调试用) |
| `... r,` | `r` = read文件规则末尾必须有逗号 |
| `/lib/** rm` | `**` 跨目录通配;`rm` = read + mmap动态库加载需要 m 权限) |
| `/etc/ld.so.cache r` | 动态链接器要读的缓存 |
| `/tmp/demo_elf.log rw` | 探针 1 的白名单 |
| `deny /etc/shadow r` | 探针 2 的显式拒绝。`deny` 的语义是**无声拒绝且不产生审计日志**——与"没写这条规则时 enforce 模式的默认拒绝(有日志)"是两种不同行为 |
### 4.2 `profile.script`Shell 脚本)
```
abi <abi/4.0>,
/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh flags=(enforce) {
/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh r,
/bin/bash rm,
/usr/bin/bash rm,
...
/usr/bin/cat rm,
/tmp/demo_sh.log rw,
deny /etc/shadow r,
}
```
关键点:
1. **attach 点是脚本路径,但真正受约束的是解释器进程**。内核 exec 脚本时会切换到 `/bin/bash` 执行AppArmor 通过脚本头部的 `#!` 识别原始路径,把 profile 转场给 bash。所以
- 脚本自身要 `r`bash 要读它);
- `/bin/bash``rm`exec 解释器 + mmap
- 脚本里用到的**每个外部命令**(这里是 `cat`)都要单独授权 —— 本 demo 故意**不**给 `cat` 授权 `/etc/shadow` 的读取,让 `cat` 本身可以 exec、但读文件时被拒从而演示"子进程继承 profile 约束"。
2. `@{HOME}` 这类**变量需要先 include tunables 才能用**`#include <tunables/global>`)。本 profile 为保持自包含、便于教学阅读,直接写绝对路径。
## 5. 运行方式
```bash
cd demo1
chmod +x run.sh demo_shell.sh
sudo sh run.sh # 必须以 root 运行:装载/卸载 profile 需要特权
```
`run.sh` 流程:
```
Pre-cleanup ─ apparmor_parser -R 卸载可能残留的旧 profile幂等容错
Step1 ─ gcc 编译 demo_app.c
Step2 ─ apparmor_parser -Q 只做语法校验,不装载
Step3 ─ cp 到 /etc/apparmor.d/ 并 apparmor_parser -r -W 装载enforce
└ 之后 sleep 6 等 audit ratelimit 窗口恢复(见 §7
Step4/5 ─ 分别运行 ELF 与脚本,收集 3 个探针的输出
Step6 ─ aa-status --json 过滤出两个 demo profile 的模式
Step7 ─ dmesg 过滤 apparmor="DENIED" 的审计记录
Step8 ─ 卸载 profile、删除 /etc/apparmor.d 下文件、清理 /tmp 探针文件
```
## 6. 实测结果report.txt 摘录)
**ELF**
```
[probe1] write /tmp/demo_elf.log: ALLOWED ← 白名单放行
[probe2] open /etc/shadow: Permission denied ← deny 规则拒绝
[probe3] open /tmp/demo_forbidden.log: Permission denied ← 默认拒绝
-rw-r--r-- 1 root root 13 /tmp/demo_elf.log ← 允许的写确实落盘
```
**脚本:**
```
[probe1] write /tmp/demo_sh.log: ALLOWED
demo_shell.sh: line 8: /usr/bin/cat: Permission denied ← cat 可 exec读 shadow 被拒
demo_shell.sh: line 12: /tmp/demo_forbidden.log: Permission denied
```
**aa-status**
```
/home/fnzhang/Project/examples/AppArmor/demo1/demo_app: enforce
/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh: enforce
```
**内核审计日志dmesg**
```
apparmor="DENIED" operation="mknod" profile="...demo_app"
name="/tmp/demo_forbidden.log" requested_mask="c" denied_mask="c"
apparmor="DENIED" operation="exec" profile="...demo_shell.sh"
name="/usr/bin/cat" requested_mask="x" denied_mask="x"
```
注意 DENIED 日志里**只有 probe3 和 `cat` 的事件,没有 `/etc/shadow`**——这正是 `deny` 规则(静默)与默认拒绝(记录日志)的区别在日志层面的直接证据。
## 7. 踩坑记录(本 demo 调通过程中实际遇到)
1. **profile 路径写死**:最初 profile 里是 `/home/fnzhang/aa_demo/...`目录已不存在。AppArmor 按可执行文件**绝对路径**匹配,路径变了 profile 就完全不生效。改 profile 前先确认二进制的实际路径。
2. **`profile 名 flags=() 路径 {}` 是非法语法**flags 必须放在 attachment 之后或用独立 `flags` 行;正确写法是 `路径 flags=(enforce) { ... }``profile 名 路径 flags=(enforce) { ... }`。非法语法会导致 `systemctl reload apparmor` 整体失败,且报错信息不在终端上、只在 `journalctl -u apparmor` 里。
3. **`@{HOME}` 未声明报错**`Found reference to variable HOME, but is never declared`——不带 `#include <tunables/global>` 时必须用绝对路径。
4. **audit ratelimit 吞掉 DENIED 日志**`systemctl reload apparmor` 会重载系统全部 200+ 个 profile产生海量 `STATUS` 审计事件,触发内核 `printk_ratelimit``kauditd_printk_skb: xxx callbacks suppressed`),把随后几秒内 demo 真正的 DENIED 事件一并丢弃。解法:
- 装载/卸载单条 profile 直接用 `apparmor_parser -r -W <file>` / `apparmor_parser -R <file>`,不要 `systemctl reload`
- 装载后 `sleep 6`(默认 ratelimit 窗口 5 秒)再触发拒绝。
5. **`deny` 规则不产生日志是设计行为**:想在日志里看到拒绝事件,应使用"不写规则、靠 enforce 默认拒绝"的方式demo 的 probe3 即为此设计)。
6. **脚本 profile 授权对象**:给脚本写 profile 时,脚本内用到的外部命令都要逐个授权;漏掉会在运行时以 `Permission denied` 暴露。
## 8. 常用命令速查
```bash
# 查看 AppArmor 是否启用
cat /sys/module/apparmor/parameters/enabled # Y
aa-status # 已装载 profile 列表与模式
# 装载 / 替换 / 卸载单条 profile-W 同时写缓存)
sudo apparmor_parser -r -W /etc/apparmor.d/xxx
sudo apparmor_parser -R /etc/apparmor.d/xxx
# 仅语法校验
apparmor_parser -Q /path/to/profile
# 查看某个进程当前的 profile
cat /proc/<pid>/attr/current
# 查看拒绝日志
sudo dmesg | grep 'apparmor="DENIED"'
sudo journalctl -k | grep apparmor
# 临时把某 profile 切到 complain 模式排查问题
sudo aa-complain /etc/apparmor.d/xxx
sudo aa-enforce /etc/apparmor.d/xxx
```
## 9. 与 SELinux 的对比(一句话版)
| | AppArmor | SELinux |
|---|----------|---------|
| 标签对象 | **路径**(文件路径 + 进程可执行路径) | inode 标签label |
| 规则粒度 | 每应用一个 profile白名单易读 | 策略全局统一类型强制TE |
| 学习成本 | 低,`aa-logprof` 可从日志生成规则 | 高 |
| 适用发行版 | Ubuntu / Debian / SUSE | RHEL / Fedora / Android |
两者都是 LSMLinux Security Module钩子实现内核只能启用其一`CONFIG_LSM` 决定)。

36
AppArmor/demo1/demo_app.c Normal file
View file

@ -0,0 +1,36 @@
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
int main(void)
{
/* 探针1: 白名单文件 —— profile 中 /tmp/demo_elf.log rw, 应当成功 */
FILE* fp = fopen("/tmp/demo_elf.log","w");
if(fp){
fprintf(fp,"elf write ok\n");
fclose(fp);
printf("[probe1] write /tmp/demo_elf.log: ALLOWED\n");
}else{
perror("[probe1] open /tmp/demo_elf.log");
}
/* 探针2: 显式 deny 规则 —— deny /etc/shadow r, 静默拒绝, 不产生审计日志 */
fp = fopen("/etc/shadow","r");
if(fp){
printf("[probe2] read /etc/shadow: ALLOWED (unexpected!)\n");
fclose(fp);
}else{
perror("[probe2] open /etc/shadow");
}
/* 探针3: 未在 profile 中列出的路径 —— 隐式默认拒绝(enforce), 拒绝并记录 DENIED 日志 */
fp = fopen("/tmp/demo_forbidden.log","w");
if(fp){
fprintf(fp,"should not be here\n");
fclose(fp);
printf("[probe3] write /tmp/demo_forbidden.log: ALLOWED (unexpected!)\n");
}else{
perror("[probe3] open /tmp/demo_forbidden.log");
}
return 0;
}

14
AppArmor/demo1/demo_shell.sh Executable file
View file

@ -0,0 +1,14 @@
#!/bin/bash
# 探针1: 白名单文件 —— profile 中 /tmp/demo_sh.log rw, 应当成功
echo "[probe1] shell try write /tmp/demo_sh.log"
echo "shell write ok" > /tmp/demo_sh.log && echo "[probe1] write /tmp/demo_sh.log: ALLOWED"
# 探针2: 显式 deny —— deny /etc/shadow r, 静默拒绝
echo "[probe2] shell try read /etc/shadow"
cat /etc/shadow && echo "[probe2] read /etc/shadow: ALLOWED (unexpected!)"
# 探针3: 未列出的路径 —— 隐式默认拒绝, 记录 DENIED 日志
echo "[probe3] shell try write /tmp/demo_forbidden.log"
echo "should not be here" > /tmp/demo_forbidden.log && echo "[probe3] write /tmp/demo_forbidden.log: ALLOWED (unexpected!)"
exit 0

View file

@ -0,0 +1,11 @@
abi <abi/4.0>,
/home/fnzhang/Project/examples/AppArmor/demo1/demo_app flags=(enforce) {
/home/fnzhang/Project/examples/AppArmor/demo1/demo_app r,
/lib/** rm,
/usr/lib/** rm,
/usr/lib64/** rm,
/etc/ld.so.cache r,
/tmp/demo_elf.log rw,
deny /etc/shadow r,
}

View file

@ -0,0 +1,27 @@
abi <abi/4.0>,
/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh flags=(enforce) {
# 解释器与自身
/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh r,
/bin/bash rm,
/usr/bin/bash rm,
/etc/ld.so.cache r,
/lib/** rm,
/usr/lib/** rm,
/usr/lib64/** rm,
# bash 运行所需的基础文件
/etc/bash.bashrc r,
/etc/profile r,
/etc/profile.d/** r,
/home/fnzhang/.bashrc r,
/home/fnzhang/.profile r,
/dev/tty rw,
# 外部命令
/usr/bin/cat rm,
# 允许写目标文件、拒绝读 shadow
/tmp/demo_sh.log rw,
deny /etc/shadow r,
}

46
AppArmor/demo1/report.txt Normal file
View file

@ -0,0 +1,46 @@
===== Precleanup: remove leftover profile =====
precleanup done
===== Step1 compile C elf =====
compile done: /home/fnzhang/Project/examples/AppArmor/demo1/demo_app
===== Step2 validate profile syntax =====
profile.elf syntax OK
profile.script syntax OK
===== Step3 install profile =====
profile installed & enforce enabled
===== Step4 run elf demo_app =====
[probe2] open /etc/shadow: Permission denied
[probe3] open /tmp/demo_forbidden.log: Permission denied
[probe1] write /tmp/demo_elf.log: ALLOWED
-- verify /tmp/demo_elf.log exists (allowed write):
-rw-r--r-- 1 root root 13 Sep 3 09:48 /tmp/demo_elf.log
===== Step5 run shell script =====
[probe1] shell try write /tmp/demo_sh.log
[probe1] write /tmp/demo_sh.log: ALLOWED
[probe2] shell try read /etc/shadow
/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh: line 8: /usr/bin/cat: Permission denied
[probe3] shell try write /tmp/demo_forbidden.log
/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh: line 12: /tmp/demo_forbidden.log: Permission denied
-- verify /tmp/demo_sh.log exists (allowed write):
-rw-r--r-- 1 root root 15 Sep 3 09:48 /tmp/demo_sh.log
===== Step6 aa-status snapshot =====
/home/fnzhang/Project/examples/AppArmor/demo1/demo_app: enforce
/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh: enforce
===== Step7 AppArmor DENIED logs =====
[397463.022144] audit: type=1400 audit(1788400106.547:1352): apparmor="DENIED" operation="mknod" class="file" profile="/home/fnzhang/Project/examples/AppArmor/demo1/demo_app" name="/tmp/demo_forbidden.log" pid=382024 comm="demo_app" requested_mask="c" denied_mask="c" fsuid=0 ouid=0
[397463.026257] audit: type=1400 audit(1788400106.552:1353): apparmor="DENIED" operation="exec" class="file" profile="/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh" name="/usr/bin/cat" pid=382031 comm="demo_shell.sh" requested_mask="x" denied_mask="x" fsuid=0 ouid=0
[397463.026325] audit: type=1400 audit(1788400106.552:1354): apparmor="DENIED" operation="open" class="file" profile="/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh" name="/etc/locale.alias" pid=382031 comm="demo_shell.sh" requested_mask="r" denied_mask="r" fsuid=0 ouid=0
[397463.026690] audit: type=1400 audit(1788400106.553:1355): apparmor="DENIED" operation="mknod" class="file" profile="/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh" name="/tmp/demo_forbidden.log" pid=382029 comm="demo_shell.sh" requested_mask="c" denied_mask="c" fsuid=0 ouid=0
[397463.026706] audit: type=1400 audit(1788400106.553:1356): apparmor="DENIED" operation="open" class="file" profile="/home/fnzhang/Project/examples/AppArmor/demo1/demo_shell.sh" name="/etc/locale.alias" pid=382029 comm="demo_shell.sh" requested_mask="r" denied_mask="r" fsuid=0 ouid=0
===== Step8 clean resources =====
========================================
Demo finished, report file: /home/fnzhang/Project/examples/AppArmor/demo1/report.txt

123
AppArmor/demo1/run.sh Executable file
View file

@ -0,0 +1,123 @@
#!/bin/bash
# 运行方法:
# step1: chmod +x run.sh demo_shell.sh
# step2: sudo sh run.sh
set -e
WORK_DIR=$(cd "$(dirname "$0")" && pwd)
cd "${WORK_DIR}"
if [ "$(id -u)" -ne 0 ];then
echo "please run with sudo: sudo sh run.sh"
exit 1
fi
ELF_BIN="${WORK_DIR}/demo_app"
SCRIPT_FILE="${WORK_DIR}/demo_shell.sh"
PROFILE_ELF_NAME="home.fnzhang.aa_demo.demo_app"
PROFILE_SCRIPT_NAME="home.fnzhang.aa_demo.demo_shell"
PROFILE_ELF="/etc/apparmor.d/${PROFILE_ELF_NAME}"
PROFILE_SCRIPT="/etc/apparmor.d/${PROFILE_SCRIPT_NAME}"
REPORT="${WORK_DIR}/report.txt"
> "${REPORT}"
log(){
echo "$1"
echo "$1" >> "${REPORT}"
}
echo "========================================"
echo "AppArmor Demo start, workdir: ${WORK_DIR}"
echo "========================================"
# ========== 【前置清理:解决上次残留冲突】 ==========
log ""
log "===== Precleanup: remove leftover profile ====="
# 卸载内核中可能残留的旧 profile-R 按 profile 名卸载)
apparmor_parser -R "${PROFILE_ELF}" 2>/dev/null || true
apparmor_parser -R "${PROFILE_SCRIPT}" 2>/dev/null || true
# 清掉 /etc/apparmor.d 下旧文件(含上次失败残留的错误 profile
rm -f "${PROFILE_ELF}" "${PROFILE_SCRIPT}"
# 注意:不使用 systemctl reload apparmor —— 它会重载全部 200+ 个 profile
# 触发内核 printk ratelimitkauditd callbacks suppressed
# 把随后 demo 真正的 DENIED 审计事件一并丢弃。
log "precleanup done"
# ========== Step1 compile C elf ==========
log ""
log "===== Step1 compile C elf ====="
gcc "${WORK_DIR}/demo_app.c" -o "${ELF_BIN}"
chmod +x "${ELF_BIN}"
log "compile done: ${ELF_BIN}"
# ========== Step2 validate profile syntax ==========
log ""
log "===== Step2 validate profile syntax ====="
apparmor_parser -Q "${WORK_DIR}/profile.elf" && log "profile.elf syntax OK"
apparmor_parser -Q "${WORK_DIR}/profile.script" && log "profile.script syntax OK"
# ========== Step3 install profile ==========
log ""
log "===== Step3 install profile ====="
cp "${WORK_DIR}/profile.elf" "${PROFILE_ELF}"
cp "${WORK_DIR}/profile.script" "${PROFILE_SCRIPT}"
apparmor_parser -r -W "${PROFILE_ELF}" || { rm -f "${PROFILE_ELF}"; log "ERROR: elf profile load failed"; exit 1; }
apparmor_parser -r -W "${PROFILE_SCRIPT}" || { rm -f "${PROFILE_SCRIPT}"; log "ERROR: script profile load failed"; exit 1; }
log "profile installed & enforce enabled"
# 等待内核 audit ratelimit 窗口恢复,避免 DENIED 事件被 printk ratelimit 吞掉
sleep 6
# ========== Step4 run elf demo_app ==========
log ""
log "===== Step4 run elf demo_app ====="
rm -f /tmp/demo_elf.log /tmp/demo_forbidden.log
"${ELF_BIN}" 2>&1 | tee -a "${REPORT}" || log "(demo_app returned non-zero / was killed - expected)"
log "-- verify /tmp/demo_elf.log exists (allowed write):"
ls -l /tmp/demo_elf.log 2>&1 | tee -a "${REPORT}"
# ========== Step5 run shell script ==========
log ""
log "===== Step5 run shell script ====="
rm -f /tmp/demo_sh.log /tmp/demo_forbidden.log
"${SCRIPT_FILE}" 2>&1 | tee -a "${REPORT}" || log "(demo_shell.sh returned non-zero / was killed - expected)"
log "-- verify /tmp/demo_sh.log exists (allowed write):"
ls -l /tmp/demo_sh.log 2>&1 | tee -a "${REPORT}"
# ========== Step6 aa-status snapshot ==========
log ""
log "===== Step6 aa-status snapshot ====="
aa-status --json 2>/dev/null | python3 -c "
import json,sys
d = json.load(sys.stdin)
for name, mode in sorted(d.get('profiles', {}).items()):
if 'demo_app' in name or 'demo_shell' in name:
print(f' {name}: {mode}')
" | tee -a "${REPORT}"
# ========== Step7 check deny evidences ==========
log ""
log "===== Step7 AppArmor DENIED logs ====="
# 等 ratelimit 窗口过去再收集sleep 6 已在 Step3 后做过,这里直接查)
dmesg 2>/dev/null | grep -iE 'apparmor=\"DENIED\"' | grep -iE 'demo_app|demo_shell' | tail -10 | tee -a "${REPORT}" \
|| journalctl -k -b --no-pager 2>/dev/null | grep -iE 'apparmor=\"DENIED\"' | grep -iE 'demo_app|demo_shell' | tail -10 | tee -a "${REPORT}"
if [ -z "$(dmesg 2>/dev/null | grep 'apparmor=\"DENIED\"' | grep -iE 'demo_app|demo_shell')" ]; then
log "(提示: 若此处为空, 可能被内核 printk ratelimit 抑制; 显式 deny 规则本身不产生日志)"
fi
# ========== Step8 clean resources ==========
log ""
log "===== Step8 clean resources ====="
apparmor_parser -R "${PROFILE_ELF}" 2>/dev/null || true
apparmor_parser -R "${PROFILE_SCRIPT}" 2>/dev/null || true
rm -f "${PROFILE_ELF}" "${PROFILE_SCRIPT}"
rm -f "${ELF_BIN}"
rm -f /tmp/demo_elf.log /tmp/demo_sh.log /tmp/demo_forbidden.log
log ""
log "========================================"
log "Demo finished, report file: ${REPORT}"

View file

@ -0,0 +1,35 @@
cmake_minimum_required(VERSION 3.15)
project(visibility_demo CXX)
set(CMAKE_CXX_STANDARD 17)
# ============ color ============
add_library(color
color/src/color.cpp
color/src/rgb_convert.cpp
)
target_include_directories(color
PUBLIC color/public # 对外头目录:用户由此 #include <color/color.h>
PRIVATE color/include # 内部头目录rgb_convert.h 只有 color 自己可见
)
# 宏出现在公开头文件的 #ifdef 里,决定 API 形状。
# 若只标 PRIVATEcolor.cpp 编译出了 brightColorCode
# 但用户看到的头文件里该声明被预处理掉 → 库和用户对 API 的理解不一致。
target_compile_definitions(color PUBLIC COLOR_ENABLE_BRIGHT)
# ============ timeutil ============
add_library(timeutil timeutil/timeutil.cpp)
target_include_directories(timeutil PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/timeutil)
# ============ printer ============
add_library(printer printer/src/printer.cpp)
target_include_directories(printer PUBLIC printer/public)
target_link_libraries(printer
PUBLIC color # printer.h 引用了 color/color.h → 契约的一部分
PRIVATE timeutil # 只有 printer.cpp 用 → 实现细节
)
# 时间格式只影响 printer.cpp 的编译 → PRIVATE不泄漏、改格式不触发下游重编
target_compile_definitions(printer PRIVATE "PRINTER_TIME_FORMAT=\"%H:%M:%S\"")
# ============ app ============
add_executable(app app/main.cpp)
target_link_libraries(app PRIVATE printer)

View file

@ -0,0 +1,64 @@
# CMake target 可见性 Demovisibility
> 来自博客 `docs/编程/20-接口可见性.md` §5.2「完整 Demo」。
> 演示 `target_include_directories` / `target_link_libraries` /
> `target_compile_definitions` 上 PUBLIC / PRIVATE / INTERFACE 的传播语义。
## 运行
```bash
cd cmake/visibility
cmake -B build && cmake --build build
./build/app
```
预期输出(终端里是彩色的):
```
10:37:06 hello (绿色)
bright red (高亮红 —— PUBLIC 宏 COLOR_ENABLE_BRIGHT 传播到 app 的证据)
(没有 "leaked!" —— printer 的 PRIVATE 宏 PRINTER_TIME_FORMAT 被隔离的证据)
```
## 目录结构
```
visibility/
├── CMakeLists.txt
├── app/
│ └── main.cpp # 最终用户,只认识 printer
├── color/
│ ├── public/color/color.h # 对外 API 头(含 PUBLIC 宏开关 COLOR_ENABLE_BRIGHT
│ ├── include/rgb_convert.h # 内部中间件头,仅库内 .cpp 共享PRIVATE 目录)
│ └── src/{color,rgb_convert}.cpp
├── timeutil/
│ └── {timeutil.h,timeutil.cpp} # printer 的私有依赖
└── printer/
├── public/printer/printer.h # 对外 API 头(引用了 color → color 是 PUBLIC 依赖)
└── src/printer.cpp
```
依赖与可见性一图流:
```
app ──PRIVATE──> printer ──PUBLIC──> color ✓ app 能 include color.h、感知 COLOR_ENABLE_BRIGHT
│ └─(内部) rgb_convert.h ✗ 外部看不见include/ 是 PRIVATE 目录)
└────PRIVATE──> timeutil ✗ app 看不见 timeutil.h
└────PRIVATE 宏 PRINTER_TIME_FORMAT ✗ 不泄漏给 app
```
## 验证实验§5.3
| # | 操作 | 实测结果 |
|---|------|---------|
| 1 | 直接编译运行 | ✅ 输出 bright red不输出 leaked! |
| 2 | main.cpp 加 `#include "rgb_convert.h"` | ✅ `fatal error: rgb_convert.h: No such file or directory` |
| 3 | main.cpp 加 `#include "timeutil.h"` | ✅ `fatal error: timeutil.h: No such file or directory` |
| 4 | printer 的 `PUBLIC color``PRIVATE color` | 未跑printer 自身编译过app 找不到 color/color.h 报错 |
## 判断口诀
打开自己的公开头文件看一眼:
- 头文件里 `#include` 了谁 → 那个库 **PUBLIC**
- 头文件里 `#ifdef` 了哪个宏(宏影响 API 形状)→ 那个宏 **PUBLIC**
- 只在 .cpp 里出现的库/宏/路径 → **PRIVATE**

View file

@ -0,0 +1,19 @@
#include <printer/printer.h>
#include <iostream>
int main() {
Printer p;
p.print("hello", Color::Green);
// COLOR_ENABLE_BRIGHT 是 color 的 PUBLIC 宏,
// 顺着 app → printer → color 传播到这里,本分支会被编译进去:
#ifdef COLOR_ENABLE_BRIGHT
std::cout << brightColorCode(Color::Red) << "bright red" << "\033[0m\n";
#endif
// PRINTER_TIME_FORMAT 是 printer 的 PRIVATE 宏,这里看不见:
#ifdef PRINTER_TIME_FORMAT
std::cout << "leaked!\n"; // 永远不会被编译进去
#endif
return 0;
}

View file

@ -0,0 +1,4 @@
#pragma once
// 内部工具:把 RGB 分量拼成 ANSI 真彩色转义码。
// 供 color 库内部多个 .cpp 共享,不属于对外 API。
const char* rgbToAnsi(int r, int g, int b);

View file

@ -0,0 +1,11 @@
#pragma once
enum class Color { Red, Green, Blue };
const char* colorCode(Color c);
// 这个宏出现在公开头文件里,直接决定 API 的形状(多不多一个函数)。
// 因此该宏必须对"用 color 的人"同样可见 → CMake 里标 PUBLIC。
#ifdef COLOR_ENABLE_BRIGHT
const char* brightColorCode(Color c); // 高亮色版本
#endif

View file

@ -0,0 +1,22 @@
#include "color/color.h"
#include "rgb_convert.h" // 内部头:能找到它,靠的是 PRIVATE 的 include/ 搜索路径
const char* colorCode(Color c) {
switch (c) {
case Color::Red: return rgbToAnsi(205, 49, 49);
case Color::Green: return rgbToAnsi(13, 188, 121);
case Color::Blue: return rgbToAnsi(36, 114, 200);
}
return "";
}
#ifdef COLOR_ENABLE_BRIGHT
const char* brightColorCode(Color c) {
switch (c) {
case Color::Red: return rgbToAnsi(241, 76, 76);
case Color::Green: return rgbToAnsi(35, 209, 139);
case Color::Blue: return rgbToAnsi(59, 142, 234);
}
return "";
}
#endif

View file

@ -0,0 +1,8 @@
#include "rgb_convert.h"
#include <cstdio>
const char* rgbToAnsi(int r, int g, int b) {
static char buf[32]; // demo 从简,非线程安全
std::snprintf(buf, sizeof(buf), "\033[38;2;%d;%d;%dm", r, g, b);
return buf;
}

View file

@ -0,0 +1,9 @@
#pragma once
#include <string>
#include <color/color.h> // 公开头引用了 color → color 必须是 PUBLIC 依赖
// 注意:这里没有 timeutil.h也没有 PRINTER_TIME_FORMAT
class Printer {
public:
void print(const std::string& msg, Color c);
};

View file

@ -0,0 +1,12 @@
#include "printer/printer.h"
#include "timeutil.h" // 只在实现里用 → timeutil 是 PRIVATE 依赖
#include <iostream>
#ifndef PRINTER_TIME_FORMAT // 只在本 .cpp 里用的宏 → PRIVATE
#define PRINTER_TIME_FORMAT "%H:%M:%S" // 没从 CMake 传进来时的兜底
#endif
void Printer::print(const std::string& msg, Color c) {
std::cout << timestamp(PRINTER_TIME_FORMAT) << " "
<< colorCode(c) << msg << "\033[0m" << std::endl;
}

View file

@ -0,0 +1,9 @@
#include "timeutil.h"
#include <ctime>
std::string timestamp(const char* fmt) {
char buf[32];
std::time_t t = std::time(nullptr);
std::strftime(buf, sizeof(buf), fmt, std::localtime(&t));
return buf;
}

View file

@ -0,0 +1,3 @@
#pragma once
#include <string>
std::string timestamp(const char* fmt); // fmt 形如 "%H:%M:%S"