sennit 0.20.0

A dotfiles manager that keeps symlink semantics, and adds templating and drift detection
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
use crate::packages::{Kind, Manager, Package, Packages};
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};

/// 宣言したものが、このマシンで実際に解決できるかを確かめる。
///
/// check が「宣言と設定」を突き合わせるのに対し、こちらは「宣言と現実」を
/// 突き合わせる。formula 名の間違い(gpg は別名で、正式には gnupg)や、
/// インストールが黙って失敗した状態を捕まえる。
///
/// 検証できるのはコマンドとフォントだけ。GUI アプリやライブラリは
/// 「PATH に無い」ことが異常を意味しないので、数だけ報告して飛ばす。
/// verify の結果を機械可読で出す。マシン間の比較に使う。
#[derive(serde::Serialize, serde::Deserialize)]
pub struct Report {
    pub os: String,
    pub profiles: Vec<String>,
    pub present: Vec<String>,
    pub missing: Vec<String>,
    pub unverifiable: Vec<String>,
}

/// 2 つの報告を比べる。どちらのマシンにあって、どちらに無いか。
///
/// verify は 1 台しか見ない。複数マシンを使っていると、片方だけ古い、
/// 片方だけ手で入れた、という差は誰も教えてくれない。
pub fn compare(a: &Path, b: &Path) -> Result<()> {
    let ta =
        std::fs::read_to_string(a).with_context(|| format!("failed to read {}", a.display()))?;
    let tb =
        std::fs::read_to_string(b).with_context(|| format!("failed to read {}", b.display()))?;
    let ra: Report =
        serde_json::from_str(&ta).with_context(|| format!("failed to parse {}", a.display()))?;
    let rb: Report =
        serde_json::from_str(&tb).with_context(|| format!("failed to parse {}", b.display()))?;

    let sa: std::collections::BTreeSet<_> = ra.present.iter().collect();
    let sb: std::collections::BTreeSet<_> = rb.present.iter().collect();

    let only_a: Vec<_> = sa.difference(&sb).collect();
    let only_b: Vec<_> = sb.difference(&sa).collect();

    println!("{} ({}) vs {} ({})", a.display(), ra.os, b.display(), rb.os);
    println!("  {} present on both", sa.intersection(&sb).count());

    if only_a.is_empty() && only_b.is_empty() {
        println!("\x1b[32mok\x1b[0m  the two machines agree");
        return Ok(());
    }
    if !only_a.is_empty() {
        println!("\nonly on {} ({}):", a.display(), ra.os);
        for n in only_a {
            println!("  \x1b[33m<\x1b[0m {n}");
        }
    }
    if !only_b.is_empty() {
        println!("\nonly on {} ({}):", b.display(), rb.os);
        for n in only_b {
            println!("  \x1b[33m>\x1b[0m {n}");
        }
    }
    Ok(())
}

/// 宣言したモードと実際のモードを突き合わせる。
///
/// トークンを含むファイルが 0644 で置かれていても何も壊れないので、
/// 気づく機会が無い。宣言してあれば検査できる。
fn check_modes(
    root: &Path,
    home: &Path,
    manifest: &crate::manifest::Manifest,
) -> Vec<(String, u32, u32)> {
    use std::os::unix::fs::PermissionsExt;
    let mut wrong = Vec::new();
    for (rel, want) in &manifest.modes {
        let Ok(want) = u32::from_str_radix(want, 8) else {
            continue;
        };
        // [merge] の出力は $HOME に在るファイルそのもの
        let base = if manifest.is_merge_output(Path::new(rel)) {
            home
        } else {
            root
        };
        let path = base.join(rel);
        let meta = match std::fs::metadata(&path) {
            Ok(m) => m,
            // 無いのは異常ではない。生成物は --secrets を通すまで作られない
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
            // 読めないのは異常。ここで黙って飛ばすと、apply が
            // 「stat できない」で落ちている同じ宣言について verify だけが
            // ok と言う。宣言した制限が誰にも適用されていない状態を
            // 「問題なし」と報告することになる。
            Err(_) => {
                // 見えないことを、食い違いとして出す
                wrong.push((rel.clone(), want, UNREADABLE));
                continue;
            }
        };
        let got = meta.permissions().mode() & 0o777;
        if got != want {
            wrong.push((rel.clone(), want, got));
        }
    }
    wrong
}

/// モードを読めなかったことを表す番兵。8 進 3 桁には収まらない値を使う。
pub const UNREADABLE: u32 = u32::MAX;

/// `secrets` なら、宣言された秘密がまだ効くかも確かめる。
///
/// パッケージとモードの検査は常に走らせ、秘密の検査は後から足す。
/// どちらかが落ちても、もう一方の結果は出す。片方の失敗がもう片方を
/// 隠すと、直して再実行するたびに次の失敗が出てくることになる。
pub fn verify(root: &Path, home: &Path, export: Option<PathBuf>, secrets: bool) -> Result<()> {
    let machine = verify_machine(root, home, export);
    if !secrets {
        return machine;
    }

    // 秘密の検査は外部に問い合わせるので、通常の検査と区切って見せる
    println!();
    let probed = crate::manifest::Manifest::load(&root.join("sennit.toml"))
        .and_then(|manifest| crate::probe::run(&manifest));
    match (machine, probed) {
        (Ok(()), Ok(())) => Ok(()),
        (Err(e), Ok(())) | (Ok(()), Err(e)) => Err(e),
        (Err(a), Err(b)) => bail!("{a:#}; {b:#}"),
    }
}

fn verify_machine(root: &Path, home: &Path, export: Option<PathBuf>) -> Result<()> {
    let packages = Packages::load(&root.join("packages.toml"))?;

    let mut missing = Vec::new();
    let mut present = Vec::new();
    let mut unverifiable = Vec::new();
    let mut checked = 0usize;
    let mut skipped = 0usize;

    for (name, pkg) in packages.applicable() {
        match verifiable(&name, pkg, crate::packages::will_install(pkg)) {
            None => {
                skipped += 1;
                unverifiable.push(name.clone());
            }
            Some(candidates) => {
                checked += 1;
                // provides のどれか 1 つでも解決すればよい。
                // neovim は nvim として入るので、パッケージ名では見つからない。
                if candidates.iter().any(|(kind, n)| resolves(*kind, n)) {
                    present.push(name.clone());
                } else {
                    let shown = candidates
                        .iter()
                        .map(|(_, n)| n.as_str())
                        .collect::<Vec<_>>()
                        .join(" / ");
                    missing.push((name.clone(), shown));
                }
            }
        }
    }

    missing.sort();

    if let Some(out) = export {
        let report = Report {
            os: crate::packages::current_os().to_string(),
            profiles: crate::packages::current_profiles(),
            present: present.clone(),
            missing: missing.iter().map(|(p, _)| p.clone()).collect(),
            unverifiable: unverifiable.clone(),
        };
        if let Some(parent) = out.parent() {
            std::fs::create_dir_all(parent).ok();
        }
        std::fs::write(&out, serde_json::to_string_pretty(&report)?)
            .with_context(|| format!("failed to write {}", out.display()))?;
        println!("wrote {}", out.display());
    }

    println!("verified {checked} package(s) against this machine ({skipped} not verifiable)");
    for (pkg, names) in &missing {
        println!("  \x1b[31mmissing\x1b[0m  {pkg}  (looked for: {names})");
    }

    let manifest = crate::manifest::Manifest::load(&root.join("sennit.toml"))?;
    let wrong_modes = check_modes(root, home, &manifest);
    for (rel, want, got) in &wrong_modes {
        if *got == UNREADABLE {
            println!("  \x1b[31mmode\x1b[0m     {rel}  (declared {want:o}, could not be read)");
        } else {
            println!("  \x1b[31mmode\x1b[0m     {rel}  (declared {want:o}, found {got:o})");
        }
    }

    // [merge] の出力が、重ねた結果とずれていないか。値は出さず、キーのパスだけ
    let merges = crate::merge::plan(root, home, &manifest)?;
    let env = crate::merge::Env::load(root, home, &manifest)?;
    let mut bad_merges = 0usize;
    for m in &merges {
        match crate::merge::status(m, &env) {
            crate::merge::Status::Ok => {}
            crate::merge::Status::Missing => {
                bad_merges += 1;
                println!(
                    "  \x1b[31mmissing\x1b[0m  {}  (merge target does not exist)",
                    m.out
                );
            }
            crate::merge::Status::Stale(keys) => {
                bad_merges += 1;
                println!(
                    "  \x1b[31mstale\x1b[0m    {}  ({} key(s) differ: {})",
                    m.out,
                    keys.len(),
                    crate::merge::describe_keys(&keys)
                );
            }
            crate::merge::Status::Broken(why) => {
                bad_merges += 1;
                println!("  \x1b[31mbroken\x1b[0m   {}  ({why})", m.out);
            }
        }
    }

    if missing.is_empty() && wrong_modes.is_empty() && bad_merges == 0 {
        println!("\x1b[32mok\x1b[0m  everything verifiable is present");
        return Ok(());
    }
    let mut problems = Vec::new();
    if !missing.is_empty() {
        problems.push(format!(
            "{} declared package(s) not found on this machine",
            missing.len()
        ));
    }
    if !wrong_modes.is_empty() {
        problems.push(format!("{} file(s) with the wrong mode", wrong_modes.len()));
    }
    if bad_merges > 0 {
        problems.push(format!("{bad_merges} merged file(s) out of date"));
    }
    bail!("{}", problems.join("; "))
}

/// このパッケージについて「存在すればこれが見つかるはず」という名前の一覧。
/// 検証しようがないものは None。
/// `installable` はこのマシンで sync が導入経路を持つか。呼び出し側から
/// 渡すのは、この判定が OS で変わるため。関数の中で cfg! を見ると、
/// 一方の OS では常に真になってテストが何も押さえられなくなる。
fn verifiable(name: &str, pkg: &Package, installable: bool) -> Option<Vec<(Kind, String)>> {
    let kind = pkg.kind_of();
    match kind {
        // 拡張はエディタが入れるので PATH には出ない
        Kind::Extension => return None,
        Kind::Font => {
            // フォントはファミリ名でしか探せない。宣言が無ければ諦める
            if pkg.provides.is_empty() {
                return None;
            }
            return Some(
                pkg.provides
                    .iter()
                    .map(|p| (Kind::Font, p.clone()))
                    .collect(),
            );
        }
        Kind::Command => {}
        // ライブラリは実行ファイルを持たない
        Kind::Library => return None,
    }

    // cask は普通 GUI アプリで PATH に出ないが、コマンドを提供するものもある
    // (1password-cli の op)。provides を書いているなら、それは
    // 「これが見つかるはず」という宣言なので検証する。
    if pkg.manager_of() == Manager::BrewCask && pkg.provides.is_empty() {
        return None;
    }
    // ただし sync がこの OS で入れないものを「無い」と言っても仕方がない。
    // Linux では apt / yay の名前が無い cask は導入経路が無く、provides を
    // 書いていると verify だけが永久に落ち続ける状態になっていた。
    if !installable {
        return None;
    }
    // mise が入れるものは shim 経由で、mise を有効化したシェルでしか
    // PATH に現れない。存在しないことが異常を意味しないので判定しない。
    if pkg.manager_of() == Manager::Mise {
        return None;
    }

    let mut out: Vec<(Kind, String)> = pkg
        .provides
        .iter()
        .map(|p| (Kind::Command, p.clone()))
        .collect();
    if out.is_empty() {
        out.push((Kind::Command, name.to_string()));
    }
    Some(out)
}

fn resolves(kind: Kind, name: &str) -> bool {
    match kind {
        Kind::Font => font_installed(name),
        _ => on_path(name),
    }
}

fn on_path(name: &str) -> bool {
    std::env::var_os("PATH")
        .map(|paths| {
            std::env::split_paths(&paths).any(|dir| {
                let p = dir.join(name);
                p.is_file() && is_executable(&p)
            })
        })
        .unwrap_or(false)
}

#[cfg(unix)]
fn is_executable(p: &Path) -> bool {
    use std::os::unix::fs::PermissionsExt;
    std::fs::metadata(p)
        .map(|m| m.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}

#[cfg(not(unix))]
fn is_executable(_p: &Path) -> bool {
    true
}

/// フォントのファイル名はファミリ名と一致しない
/// (Hack Nerd Font Mono -> HackNerdFontMono-Regular.ttf)。
/// 空白を除いた前方一致で探す。
fn font_installed(family: &str) -> bool {
    let needle = family.replace(' ', "").to_lowercase();
    font_dirs().iter().any(|dir| {
        std::fs::read_dir(dir)
            .map(|entries| {
                entries.filter_map(|e| e.ok()).any(|e| {
                    e.file_name()
                        .to_string_lossy()
                        .replace(' ', "")
                        .to_lowercase()
                        .starts_with(&needle)
                })
            })
            .unwrap_or(false)
    })
}

fn font_dirs() -> Vec<PathBuf> {
    let home = std::env::var("HOME").unwrap_or_default();
    if cfg!(target_os = "macos") {
        vec![
            PathBuf::from(&home).join("Library/Fonts"),
            PathBuf::from("/Library/Fonts"),
            PathBuf::from("/System/Library/Fonts"),
        ]
    } else {
        vec![
            PathBuf::from(&home).join(".local/share/fonts"),
            PathBuf::from(&home).join(".fonts"),
            PathBuf::from("/usr/share/fonts"),
            PathBuf::from("/usr/local/share/fonts"),
        ]
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn pkg(src: &str) -> Package {
        toml::from_str(src).unwrap()
    }

    /// provides があればそちらを探す。neovim は nvim としてしか PATH に出ない。
    #[test]
    fn looks_for_provided_names_not_the_package_name() {
        let p = pkg("provides = [\"nvim\"]\n");
        let got = verifiable("neovim", &p, true).unwrap();
        assert_eq!(got, vec![(Kind::Command, "nvim".to_string())]);
    }

    #[test]
    fn falls_back_to_the_package_name() {
        let p = pkg("");
        let got = verifiable("bat", &p, true).unwrap();
        assert_eq!(got, vec![(Kind::Command, "bat".to_string())]);
    }

    /// GUI アプリは PATH に出ないので検証対象にしない。
    /// ここを外すと 1password や slack が毎回 missing になる。
    #[test]
    fn gui_casks_are_not_verifiable() {
        let p = pkg("manager = \"brew-cask\"\n");
        assert!(verifiable("slack", &p, true).is_none());
    }

    /// ただし provides を書いた cask は検証する。1password-cli は cask だが
    /// op を提供するので、無ければ秘密の展開が落ちる。
    #[test]
    fn casks_that_declare_a_command_are_verified() {
        let p = pkg("manager = \"brew-cask\"\nprovides = [\"op\"]\n");
        let got = verifiable("1password-cli", &p, true).unwrap();
        assert_eq!(got, vec![(Kind::Command, "op".to_string())]);
    }

    /// フォントの cask は検証できる。ファミリ名の宣言がある場合に限る。
    #[test]
    fn font_casks_are_verified_by_family_name() {
        let p =
            pkg("manager = \"brew-cask\"\nkind = \"font\"\nprovides = [\"Hack Nerd Font Mono\"]\n");
        let got = verifiable("font-hack-nerd-font", &p, true).unwrap();
        assert_eq!(got, vec![(Kind::Font, "Hack Nerd Font Mono".to_string())]);
    }

    /// mise の shim は mise を有効化したシェルにしか出ない。
    /// このマシンで入れようがないものを「無い」と報告しない。
    ///
    /// Linux で apt / yay の名前が無い cask がこれに当たる。sync は入れず、
    /// verify だけが missing と言い続け、利用者にできることが何も無い。
    #[test]
    fn what_cannot_be_installed_here_is_not_verified() {
        let p: Package = toml::from_str("manager = \"brew-cask\"\nprovides = [\"op\"]\n").unwrap();
        assert!(verifiable("1password-cli", &p, true).is_some());
        assert!(verifiable("1password-cli", &p, false).is_none());
    }

    #[test]
    fn mise_managed_tools_are_not_verifiable() {
        let p = pkg("manager = \"mise\"\n");
        assert!(verifiable("go", &p, true).is_none());
    }

    /// ライブラリは実行ファイルを持たない。
    #[test]
    fn libraries_are_not_verifiable() {
        let p = pkg("kind = \"library\"\n");
        assert!(verifiable("libyaml", &p, true).is_none());
    }
}