-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_loadout.py
More file actions
1189 lines (989 loc) · 49 KB
/
Copy pathtest_loadout.py
File metadata and controls
1189 lines (989 loc) · 49 KB
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
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""skill-loadout 测试 —— 重点是 spec 里的强制不变量。
跑:LOADOUT_LIBRARY / LOADOUT_TARGETS 环境变量在每个 fixture 里指向 tmp 沙盒。
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
import loadout as L
# ── 沙盒搭建 ───────────────────────────────────────────────────────────
def make_skill(lib: Path, name: str, description: str = "", refs: list[str] | None = None):
"""在库里造一个 skill 目录 + SKILL.md(可含 ../ref 依赖)。"""
d = lib / name
d.mkdir(parents=True, exist_ok=True)
body = f"---\nname: {name}\ndescription: \"{description}\"\n---\n\n# {name}\n"
for r in refs or []:
body += f"\n先 Read [../{r}/SKILL.md] 再操作。\n"
(d / "SKILL.md").write_text(body, encoding="utf-8")
return d
def write_preset(lib: Path, name: str, lines: list[str]):
pdir = lib / "presets"
pdir.mkdir(parents=True, exist_ok=True)
(pdir / f"{name}.list").write_text("\n".join(lines) + "\n", encoding="utf-8")
@pytest.fixture
def sandbox(tmp_path, monkeypatch):
lib = tmp_path / "lib"
lib.mkdir()
t1 = tmp_path / "claude" / "skills"
t2 = tmp_path / "agents" / "skills"
t1.mkdir(parents=True)
t2.mkdir(parents=True)
monkeypatch.setenv("LOADOUT_LIBRARY", str(lib))
monkeypatch.setenv("LOADOUT_TARGETS", os.pathsep.join([str(t1), str(t2)]))
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex"))
monkeypatch.setenv("NO_COLOR", "1")
return {"lib": lib, "targets": [t1, t2]}
# ── 闭包展开 ───────────────────────────────────────────────────────────
def test_closure_pulls_lark_shared(sandbox):
"""spec 验收用例:点名 lark-minutes 自动带出全部 sibling 依赖。"""
lib = sandbox["lib"]
deps = ["lark-shared", "lark-vc", "lark-note", "lark-task", "lark-contact", "lark-drive", "lark-calendar", "lark-doc"]
make_skill(lib, "lark-minutes", "妙记", refs=deps)
for d in deps:
make_skill(lib, d, d)
plan = L.plan_selection({"lark-minutes"}, lib, sandbox["targets"], "test")
assert set(plan["selected"]) == {"lark-minutes", *deps}
for d in deps:
assert d in plan["deps_added"]
def test_closure_ignores_unknown_ref(sandbox):
"""../transcript/ 这类库中不存在的引用名必须被忽略,不报错不拉入。"""
lib = sandbox["lib"]
make_skill(lib, "lark-minutes", "妙记", refs=["transcript", "lark-shared"])
make_skill(lib, "lark-shared", "shared")
plan = L.plan_selection({"lark-minutes"}, lib, sandbox["targets"], "test")
assert "transcript" not in plan["selected"]
assert "lark-shared" in plan["selected"]
# 校验也不能因 transcript 悬空而报错
assert L.validate_plan(plan, lib, sandbox["targets"]) == []
def test_validate_catches_real_dangling(sandbox):
"""真正的漏依赖(库中有但 preset 排除)必须被校验拦下。"""
lib = sandbox["lib"]
make_skill(lib, "a", "a", refs=["b"])
make_skill(lib, "b", "b")
# 手工构造一个不含闭包的畸形计划:直接调 validate
plan = {"selected": ["a"]} # 故意漏 b
errors = L.validate_plan(plan, lib, sandbox["targets"])
assert any("../b/" in e for e in errors)
# ── 所有权铁律(安全根基)───────────────────────────────────────────────
def test_rematerialize_never_touches_real_dirs(sandbox):
"""交替物化两个选择各两次,真实目录和库外链接必须原封不动。"""
lib, (t1, t2) = sandbox["lib"], sandbox["targets"]
make_skill(lib, "x", "x")
make_skill(lib, "y", "y")
# 种一个真实目录 + 一个指向库外的链接
(t1 / "official-skill").mkdir()
(t1 / "official-skill" / "SKILL.md").write_text("real", encoding="utf-8")
outside = sandbox["lib"].parent / "outside"
outside.mkdir()
(t1 / "external-link").symlink_to(outside)
for names in [{"x"}, {"y"}, {"x"}, {"y"}]:
L.apply_plan(L.plan_selection(names, lib, sandbox["targets"], "test"),
lib, sandbox["targets"])
assert (t1 / "official-skill").is_dir()
assert not (t1 / "official-skill").is_symlink()
assert (t1 / "official-skill" / "SKILL.md").read_text() == "real"
assert (t1 / "external-link").is_symlink()
assert os.readlink(t1 / "external-link") == str(outside)
def test_is_owned_distinguishes(sandbox):
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
make_skill(lib, "x", "x")
L.make_link(t1 / "x", lib, "x")
(t1 / "real").mkdir()
(t1 / "ext").symlink_to(sandbox["lib"].parent)
assert L.is_owned(t1 / "x", lib) is True
assert L.is_owned(t1 / "real", lib) is False
assert L.is_owned(t1 / "ext", lib) is False
def test_owned_link_to_deleted_skill_is_not_owned(sandbox):
"""断链无法再证明由本工具创建,必须按 foreign 保留。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
make_skill(lib, "gone", "g")
L.make_link(t1 / "gone", lib, "gone")
shutil.rmtree(lib / "gone")
assert L.is_owned(t1 / "gone", lib) is False
def test_is_owned_rejects_library_non_skill_child(sandbox):
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
non_skill = lib / "notes"
non_skill.mkdir()
(t1 / "notes").symlink_to(non_skill)
assert L.is_owned(t1 / "notes", lib) is False
@pytest.mark.parametrize("name", [".private", "bad name"])
def test_is_owned_rejects_library_children_that_are_not_valid_skills(
sandbox, name):
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
child = lib / name
child.mkdir()
(child / "SKILL.md").write_text("---\nname: hidden\n---\n", encoding="utf-8")
link = t1 / "candidate"
link.symlink_to(child)
assert L.is_owned(link, lib) is False
# ── 幂等性 ─────────────────────────────────────────────────────────────
def test_apply_selection_idempotent(sandbox):
lib, targets = sandbox["lib"], sandbox["targets"]
make_skill(lib, "x", "x")
make_skill(lib, "y", "y")
L.apply_plan(L.plan_selection({"x", "y"}, lib, targets, "test"), lib, targets)
plan2 = L.plan_selection({"x", "y"}, lib, targets, "test")
for t in targets:
info = plan2["per_target"][str(t)]
assert info["to_create"] == []
assert info["to_remove"] == []
def test_switch_selection_removes_old_links(sandbox):
lib, (t1, t2) = sandbox["lib"], sandbox["targets"]
make_skill(lib, "x", "x")
make_skill(lib, "y", "y")
targets = sandbox["targets"]
L.apply_plan(L.plan_selection({"x"}, lib, targets, "test"), lib, targets)
assert (t1 / "x").is_symlink()
L.apply_plan(L.plan_selection({"y"}, lib, targets, "test"), lib, targets)
assert not (t1 / "x").exists()
assert (t1 / "y").is_symlink()
def test_missing_skill_errors(sandbox):
lib = sandbox["lib"]
make_skill(lib, "x", "x")
with pytest.raises(L.PlanError):
L.plan_selection({"x", "nonexistent"}, lib, sandbox["targets"], "test")
# ── migrate(沙盒里造真实的两目录现状,验证行为中性 & 不自动决策)────────
def make_real_skill(target: Path, name: str, description: str):
d = target / name
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: \"{description}\"\n---\n# {name}\n",
encoding="utf-8")
return d
@pytest.fixture
def migrate_sandbox(tmp_path, monkeypatch):
"""migrate 需要一个空库 + 两个装着真实 skill 的目标目录。"""
home = tmp_path / "home"
home.mkdir()
lib = home / ".skill-library"
t1 = home / ".claude" / "skills"
t2 = home / ".agents" / "skills"
t1.mkdir(parents=True)
t2.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("LOADOUT_LIBRARY", str(lib))
monkeypatch.setenv("LOADOUT_TARGETS", os.pathsep.join([str(t1), str(t2)]))
monkeypatch.setenv("NO_COLOR", "1")
return {"home": home, "lib": lib, "t1": t1, "t2": t2}
def test_migrate_behavior_neutral(migrate_sandbox):
"""迁移物化后,两端解析出的 skill 集合与迁移前完全一致。"""
s = migrate_sandbox
make_real_skill(s["t1"], "alpha", "a")
make_real_skill(s["t1"], "beta", "b")
make_real_skill(s["t2"], "gamma", "c")
before = L.snapshot([s["t1"], s["t2"]], s["lib"])
rc = L.main(["migrate", "--assume-skip"])
assert rc == 0
after = L.snapshot([s["t1"], s["t2"]], s["lib"])
# 每个原本就在的 (目标,名) 内容不变;镜像到新端属预期新增,故非全等
assert all(after.get(k) == v for k, v in before.items())
# 都进了库
assert L.library_skills(s["lib"]) == {"alpha", "beta", "gamma"}
def test_migrate_makes_backup(migrate_sandbox):
s = migrate_sandbox
make_real_skill(s["t1"], "alpha", "a")
L.main(["migrate", "--assume-skip"])
backups = list(s["home"].glob("skill-loadout-backup-*.tar.gz"))
assert len(backups) == 1
def test_migrate_conflict_not_auto_resolved(migrate_sandbox):
"""同名不同内容 = 冲突;--assume-skip 下必须原地不动、两份都不入库。"""
s = migrate_sandbox
make_real_skill(s["t1"], "dup", "version-one")
make_real_skill(s["t2"], "dup", "version-two-different")
rc = L.main(["migrate", "--assume-skip"])
assert rc == 0
# 跳过 → 库里没有 dup,两个原始目录仍在
assert "dup" not in L.library_skills(s["lib"])
assert (s["t1"] / "dup").is_dir()
assert (s["t2"] / "dup").is_dir()
def test_migrate_identical_dupes_merge(migrate_sandbox):
"""同名同内容 = 非冲突,入库一份。"""
s = migrate_sandbox
make_real_skill(s["t1"], "same", "identical")
make_real_skill(s["t2"], "same", "identical")
L.main(["migrate", "--assume-skip"])
assert "same" in L.library_skills(s["lib"])
# ── 回归:所有权铁律 + 归一彻底性 + 漂移判据 ────────────────────────────
def test_migrate_never_moves_external_symlink(migrate_sandbox):
"""target 里指向库外的软链:migrate 绝不 resolve+move 其实体,原址保留。"""
s = migrate_sandbox
external = s["home"] / "external-store" / "ext-skill"
external.mkdir(parents=True)
(external / "SKILL.md").write_text(
'---\nname: ext-skill\ndescription: "x"\n---\n', encoding="utf-8")
(s["t1"] / "ext-skill").symlink_to(external)
make_real_skill(s["t1"], "normal", "n")
assert L.main(["migrate", "--assume-skip"]) == 0
# 外部实体原址仍在,未被搬进库
assert external.is_dir() and (external / "SKILL.md").is_file()
assert "ext-skill" not in L.library_skills(s["lib"])
# 原软链原样保留(常驻,不碰)
assert (s["t1"] / "ext-skill").is_symlink()
assert os.readlink(s["t1"] / "ext-skill") == str(external)
# 正常真实目录照常入库
assert "normal" in L.library_skills(s["lib"])
@pytest.mark.parametrize("symlink_level", ["target", "parent"])
def test_migrate_rejects_symlinked_target_root_or_ancestor_before_backup(
migrate_sandbox, tmp_path, symlink_level):
s = migrate_sandbox
s["t1"].rmdir()
external = tmp_path / f"external-{symlink_level}"
if symlink_level == "target":
external.mkdir()
s["t1"].symlink_to(external)
physical_target = external
else:
s["t1"].parent.rmdir()
(external / "skills").mkdir(parents=True)
s["t1"].parent.symlink_to(external)
physical_target = external / "skills"
skill = make_real_skill(physical_target, "demo", "EXTERNAL")
before = (skill / "SKILL.md").read_bytes()
assert L.main(["migrate", "--assume-skip"]) == 1
assert (skill / "SKILL.md").read_bytes() == before
assert skill.is_dir() and not skill.is_symlink()
assert not (s["lib"] / "demo").exists()
assert list(s["home"].glob("skill-loadout-backup-*.tar.gz")) == []
def test_migrate_rejects_symlinked_settings_ancestor_before_backup(
migrate_sandbox, tmp_path, monkeypatch):
s = migrate_sandbox
first = s["home"] / "targets" / "one"
second = s["home"] / "targets" / "two"
first.mkdir(parents=True)
second.mkdir(parents=True)
make_real_skill(first, "demo", "DEMO")
monkeypatch.setenv("LOADOUT_TARGETS", os.pathsep.join(map(str, [first, second])))
shutil.rmtree(s["t1"].parent)
external = tmp_path / "external-claude"
external.mkdir()
settings = external / "settings.json"
settings.write_text('{"keep": true}\n', encoding="utf-8")
before = settings.read_bytes()
s["t1"].parent.symlink_to(external)
assert L.main(["migrate", "--assume-skip"]) == 1
assert settings.read_bytes() == before
assert not (s["lib"] / "demo").exists()
assert list(s["home"].glob("skill-loadout-backup-*.tar.gz")) == []
def test_migrate_identical_dupes_no_stray_copy(migrate_sandbox):
"""同名同内容分处两端:一端入库,另一端冗余实体删除后回链,两端均为链接。"""
s = migrate_sandbox
make_real_skill(s["t1"], "same", "identical")
make_real_skill(s["t2"], "same", "identical")
assert L.main(["migrate", "--assume-skip"]) == 0
assert "same" in L.library_skills(s["lib"])
for t in (s["t1"], s["t2"]):
assert (t / "same").is_symlink()
assert L.is_owned(t / "same", s["lib"])
def test_migrate_script_diff_is_conflict(migrate_sandbox):
"""SKILL.md 相同但脚本不同 = 真冲突(全目录 hash);--assume-skip 下两份都不入库。"""
s = migrate_sandbox
d1 = make_real_skill(s["t1"], "tool", "same")
d2 = make_real_skill(s["t2"], "tool", "same")
(d1 / "run.sh").write_text("echo one\n", encoding="utf-8")
(d2 / "run.sh").write_text("echo two\n", encoding="utf-8")
assert L.main(["migrate", "--assume-skip"]) == 0
assert "tool" not in L.library_skills(s["lib"])
assert (s["t1"] / "tool").is_dir() and (s["t2"] / "tool").is_dir()
def test_check_detects_non_skillmd_drift(sandbox, capsys):
"""SKILL.md 逐字节相同、脚本不同的库外副本,check 必须报漂移。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
make_skill(lib, "s", "d")
(lib / "s" / "run.sh").write_text("echo lib\n", encoding="utf-8")
(t1 / "s").mkdir()
(t1 / "s" / "SKILL.md").write_bytes((lib / "s" / "SKILL.md").read_bytes())
(t1 / "s" / "run.sh").write_text("echo drifted\n", encoding="utf-8")
write_preset(lib, "p", ["s"])
rc = L.main(["check"])
assert "与库内 s" in capsys.readouterr().out # 漂移被报告
assert rc == 1
def test_deselect_root_unlinks_orphan_dep(sandbox):
"""选择集去掉根 skill 后,仅因它被拉入的自动依赖也应卸下。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
targets = sandbox["targets"]
make_skill(lib, "root", "r", refs=["dep"])
make_skill(lib, "dep", "d")
L.apply_plan(L.plan_selection({"root"}, lib, targets, "test"), lib, targets)
assert (t1 / "root").is_symlink() and (t1 / "dep").is_symlink() # 闭包自动拉入
L.apply_plan(L.plan_selection(set(), lib, targets, "test"), lib, targets)
assert not (t1 / "root").exists()
assert not (t1 / "dep").exists() # 孤立依赖被卸下
# ── 回归:所有权整链解析 / hash 灵敏度 / 冲突全端归一 ────────────────────
def test_is_owned_rejects_multihop_external(sandbox):
"""x -> lib/alias -> /outside:整链解析落在库外,不得判为自有。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
outside = lib.parent / "outside-real"
outside.mkdir()
(lib / "alias").symlink_to(outside) # 库内一个指向库外的别名链
(t1 / "x").symlink_to(lib / "alias") # 目标端二跳链
assert L.is_owned(t1 / "x", lib) is False
def test_apply_corrects_mistargeted_owned_link(sandbox):
"""自有链 x -> lib/y(指错名)应被物化纠正为 x -> lib/x。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
targets = sandbox["targets"]
make_skill(lib, "x", "x")
make_skill(lib, "y", "y")
(t1 / "x").symlink_to(os.path.relpath(lib / "y", t1)) # 指错名
assert L.is_owned(t1 / "x", lib) # 仍是自有链(落在库内)
assert not L._points_to(t1 / "x", lib / "x")
L.apply_plan(L.plan_selection({"x"}, lib, targets, "test"), lib, targets)
assert L._points_to(t1 / "x", lib / "x") # 已纠正
def test_skill_hash_captures_exec_symlink_emptydir(sandbox):
"""可执行位 / 符号链接目标 / 空目录变化都应改变 hash(防误判 identical 误删)。"""
lib = sandbox["lib"]
a, b = lib / "a", lib / "b"
for d in (a, b):
d.mkdir()
(d / "SKILL.md").write_text('---\nname: s\ndescription: "d"\n---\n', encoding="utf-8")
(d / "run.sh").write_text("echo hi\n", encoding="utf-8")
assert L.skill_hash(a) == L.skill_hash(b) # 逐字节相同
os.chmod(a / "run.sh", 0o755) # 1) 可执行位
assert L.skill_hash(a) != L.skill_hash(b)
os.chmod(a / "run.sh", 0o644)
assert L.skill_hash(a) == L.skill_hash(b)
(a / "lnk").symlink_to("tx") # 2) 符号链接目标串
(b / "lnk").symlink_to("ty")
assert L.skill_hash(a) != L.skill_hash(b)
(b / "lnk").unlink()
(b / "lnk").symlink_to("tx")
assert L.skill_hash(a) == L.skill_hash(b)
(a / "empty").mkdir() # 3) 空目录
assert L.skill_hash(a) != L.skill_hash(b)
def test_migrate_conflict_resolved_relinks_all_ends(migrate_sandbox, monkeypatch):
"""人工选定冲突版本后:两端都指向所选库版本,落选真实副本被移除。"""
s = migrate_sandbox
make_real_skill(s["t1"], "dup", "version-one")
make_real_skill(s["t2"], "dup", "version-two-different")
monkeypatch.setattr("builtins.input", lambda *a, **k: "1") # 选 [1] = t1 版本
assert L.main(["migrate"]) == 0
assert "dup" in L.library_skills(s["lib"])
for t in (s["t1"], s["t2"]):
assert (t / "dup").is_symlink()
assert L.is_owned(t / "dup", s["lib"])
assert "version-one" in (s["lib"] / "dup" / "SKILL.md").read_text()
# ── 回归:hash 无碰撞 / 读取失败中止 / 库内既有版本不被静默旁路 ─────────────
def test_skill_hash_no_delimiter_collision(sandbox):
"""两棵不同文件树不得因 NUL 分隔符拼接而 hash 相同(否则 migrate 会误删一份)。"""
lib = sandbox["lib"]
a, b = lib / "ta", lib / "tb"
a.mkdir()
b.mkdir()
# A: 两个文件 a=x, b=y
(a / "a").write_bytes(b"x")
(a / "b").write_bytes(b"y")
# B: 单文件 a,其内容正好拼出旧无长度方案里 A 的后续字节流
(b / "a").write_bytes(b"x\x00f\x00b\x000\x00y")
assert L.skill_hash(a) != L.skill_hash(b)
@pytest.mark.skipif(os.getuid() == 0, reason="root 无视权限位,无法制造不可读文件")
def test_skill_hash_raises_on_unreadable(sandbox):
"""文件不可读 → skill_hash 抛错,_hashes_equal 判 False(绝不冒充相等)。"""
lib = sandbox["lib"]
d = lib / "u"
d.mkdir()
f = d / "secret"
f.write_text("x", encoding="utf-8")
os.chmod(f, 0o000)
try:
with pytest.raises(OSError):
L.skill_hash(d)
assert L._hashes_equal(d, d) is False # 同一目录也不判等
finally:
os.chmod(f, 0o644)
@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="平台无 mkfifo")
def test_skill_hash_rejects_special_nodes(sandbox):
"""FIFO/socket 等特殊节点必须被拒绝(抛错),否则两棵异构树会判等致误删。"""
lib = sandbox["lib"]
d1, d2 = lib / "f1", lib / "f2"
d1.mkdir()
d2.mkdir()
os.mkfifo(d1 / "pipe")
os.mkfifo(d2 / "other") # 不同路径的 FIFO
with pytest.raises(OSError):
L.skill_hash(d1)
assert L._hashes_equal(d1, d2) is False # fail-closed,绝不判等
@pytest.mark.skipif(os.getuid() == 0, reason="root 无视权限位")
def test_skill_hash_raises_on_unwalkable_subdir(sandbox):
"""不可遍历的子目录不得被静默吞掉——必须抛错(fail-closed)。"""
lib = sandbox["lib"]
d = lib / "x"
sub = d / "sub"
sub.mkdir(parents=True)
(sub / "f").write_text("y", encoding="utf-8")
os.chmod(sub, 0o000)
try:
with pytest.raises(OSError):
L.skill_hash(d)
finally:
os.chmod(sub, 0o755)
def test_migrate_preexisting_lib_identical_dedups(migrate_sandbox):
"""库内已有与目标『逐字节相同』的版本:应继续归一(两端回链、真实副本删除)。"""
s = migrate_sandbox
s["lib"].mkdir(parents=True, exist_ok=True)
make_real_skill(s["lib"], "dup", "same") # 库内既有版本
make_real_skill(s["t1"], "dup", "same") # 目标端逐字节相同
assert L.main(["migrate", "--assume-skip"]) == 0
assert (s["t1"] / "dup").is_symlink() # 被去重回链,而非保留真实目录
assert L.is_owned(s["t1"] / "dup", s["lib"])
assert (s["t2"] / "dup").is_symlink() # 另一端也镜像回链
def test_migrate_materializes_library_only_skill_to_all_ends(migrate_sandbox):
"""库内已有 x、仅一端有链接、另一端为空:migrate 后两端都应有 x(full active 须自洽)。"""
s = migrate_sandbox
s["lib"].mkdir(parents=True, exist_ok=True)
make_real_skill(s["lib"], "x", "x") # 库内已有 x(本次无实体 source)
(s["t1"] / "x").symlink_to(os.path.relpath(s["lib"] / "x", s["t1"])) # 仅 Claude 端有链
assert L.main(["migrate", "--assume-skip"]) == 0
for t in (s["t1"], s["t2"]): # Agents 端也被补齐
assert (t / "x").is_symlink()
assert L.is_owned(t / "x", s["lib"])
def test_migrate_resolved_missing_end_fails_acceptance(tmp_path, monkeypatch):
"""resolved 名在某端完全缺失(该端是断掉的库外软链)→ 验收必须失败,不放过。"""
home = tmp_path / "home"
home.mkdir()
lib = home / ".skill-library"
t1, t2, t3 = home / ".claude/skills", home / ".agents/skills", home / ".other/skills"
for t in (t1, t2, t3):
t.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("LOADOUT_LIBRARY", str(lib))
monkeypatch.setenv("LOADOUT_TARGETS", os.pathsep.join([str(t1), str(t2), str(t3)]))
monkeypatch.setenv("NO_COLOR", "1")
make_real_skill(t1, "dup", "v1")
make_real_skill(t2, "dup", "v2")
(t3 / "dup").symlink_to(home / "nonexistent-external") # 断掉的库外软链,relink 不碰
monkeypatch.setattr("builtins.input", lambda *a, **k: "1") # 选 t1
assert L.main(["migrate"]) == 1 # t3 端缺 canonical → 验收失败
def test_migrate_preexisting_lib_version_blocks_pick(migrate_sandbox, monkeypatch):
"""库内已有异版本时:所选不生效则不静默用旧版——跳过,库版与源都原样保留。"""
s = migrate_sandbox
s["lib"].mkdir(parents=True, exist_ok=True)
old = s["lib"] / "dup"
old.mkdir()
(old / "SKILL.md").write_text('---\nname: dup\ndescription: "OLD"\n---\n', encoding="utf-8")
make_real_skill(s["t1"], "dup", "PICK-one")
make_real_skill(s["t2"], "dup", "version-two")
monkeypatch.setattr("builtins.input", lambda *a, **k: "1") # 选 t1 = PICK-one
assert L.main(["migrate"]) == 0
# 库内旧版未被覆盖
assert "OLD" in (s["lib"] / "dup" / "SKILL.md").read_text()
# 所选源与另一端副本都原样保留(未删、未静默链到旧版)
assert (s["t1"] / "dup").is_dir()
assert "PICK-one" in (s["t1"] / "dup" / "SKILL.md").read_text()
assert (s["t2"] / "dup").is_dir()
# ── 第 6 轮:路径 confinement / 原子写 / fail-closed / 健康门禁 / hook 安全 ──
@pytest.mark.skipif(os.getuid() == 0, reason="root 无视权限位")
def test_closure_fail_closed_on_unreadable_md(sandbox):
"""闭包扫描遇不可读 Markdown → 报错中止,不 fail-open 漏依赖。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
make_skill(lib, "x", "x")
bad = lib / "x" / "extra.md"
bad.write_text("../y/ 引用", encoding="utf-8")
os.chmod(bad, 0o000)
make_skill(lib, "y", "y")
try:
with pytest.raises(OSError):
L.plan_selection({"x"}, lib, sandbox["targets"], "test")
assert not (t1 / "x").exists() # fail-closed,未改动
finally:
os.chmod(bad, 0o644)
def test_skill_hash_captures_permission_bits(sandbox):
"""非执行位与目录权限差异都应改变 hash(0600≠0644、0700≠0755)。"""
lib = sandbox["lib"]
a, b = lib / "pa", lib / "pb"
for d in (a, b):
d.mkdir()
(d / "SKILL.md").write_text("x", encoding="utf-8")
assert L.skill_hash(a) == L.skill_hash(b)
os.chmod(a / "SKILL.md", 0o600)
assert L.skill_hash(a) != L.skill_hash(b)
os.chmod(a / "SKILL.md", 0o644)
assert L.skill_hash(a) == L.skill_hash(b)
(a / "sub").mkdir()
(b / "sub").mkdir()
os.chmod(a / "sub", 0o700)
os.chmod(b / "sub", 0o755)
assert L.skill_hash(a) != L.skill_hash(b)
def test_migrate_rejects_library_symlink_skill(migrate_sandbox):
"""库内 x 是软链(非真实 skill 目录)→ 不当 canonical,目标真实副本不被删。"""
s = migrate_sandbox
s["lib"].mkdir(parents=True, exist_ok=True)
ext = s["home"] / "ext"
ext.mkdir()
(ext / "SKILL.md").write_text('---\nname: x\ndescription: "e"\n---\n', encoding="utf-8")
(s["lib"] / "x").symlink_to(ext)
make_real_skill(s["t1"], "x", "real-x")
assert L.main(["migrate", "--assume-skip"]) == 0
assert (s["t1"] / "x").is_dir()
assert "real-x" in (s["t1"] / "x" / "SKILL.md").read_text()
def test_migrate_conflict_missing_skillmd_no_crash(migrate_sandbox):
"""冲突副本缺 SKILL.md 时也不得崩(--assume-skip 也会走到 diff 分支)。"""
s = migrate_sandbox
(s["t1"] / "c").mkdir(parents=True)
(s["t1"] / "c" / "a.txt").write_text("1", encoding="utf-8")
(s["t2"] / "c").mkdir(parents=True)
(s["t2"] / "c" / "a.txt").write_text("2", encoding="utf-8")
assert L.main(["migrate", "--assume-skip"]) == 0
assert "c" not in L.library_skills(s["lib"])
def test_check_reports_missing_project_mode(sandbox, capsys):
"""check 作为健康门禁:尚未切换项目模式算 finding(rc≠0)。"""
lib = sandbox["lib"]
make_skill(lib, "x", "x")
assert L.main(["check"]) == 1
assert "尚未切换项目模式" in capsys.readouterr().out
def test_library_skills_requires_skillmd(sandbox):
"""无 SKILL.md 的真实目录不算 skill。"""
lib = sandbox["lib"]
make_skill(lib, "x", "x")
make_skill(lib, "z", "z")
(lib / "junk").mkdir() # 无 SKILL.md
assert L.library_skills(lib) == {"x", "z"}
def test_closure_allows_dotted_dep_name(sandbox):
"""dep.v1 这类带点命名的依赖必须能被闭包识别。"""
lib = sandbox["lib"]
make_skill(lib, "main", "m", refs=["dep.v1"])
make_skill(lib, "dep.v1", "d")
plan = L.plan_selection({"main"}, lib, sandbox["targets"], "test")
assert "dep.v1" in plan["selected"]
# ── 第 7 轮:备份 manifest + 一键还原 ────────────────────────────────────
def test_migrate_then_restore_roundtrip(migrate_sandbox):
"""migrate 后 restore 精确回到基线:库被移除、目标恢复真实目录、settings 复原。"""
import json
s = migrate_sandbox
make_real_skill(s["t1"], "alpha", "a")
make_real_skill(s["t2"], "beta", "b")
claude = s["home"] / ".claude"
claude.mkdir(parents=True, exist_ok=True)
(claude / "settings.json").write_text('{"x": 1}', encoding="utf-8")
assert L.main(["migrate", "--assume-skip"]) == 0
# 迁移把两端变成软链、建了库;settings 不再被 migrate 触碰
assert L.library_skills(s["lib"]) == {"alpha", "beta"}
assert (s["t1"] / "alpha").is_symlink()
assert json.loads((claude / "settings.json").read_text()) == {"x": 1}
backups = list(s["home"].glob("skill-loadout-backup-*.tar.gz"))
assert len(backups) == 1
assert (backups[0].stat().st_mode & 0o777) == 0o600 # 私有
assert L.main(["restore", str(backups[0])]) == 0
# 库迁移前不存在 → 被移除
assert not s["lib"].exists()
# 目标恢复为真实目录(非软链),内容原样
assert (s["t1"] / "alpha").is_dir() and not (s["t1"] / "alpha").is_symlink()
assert "a" in (s["t1"] / "alpha" / "SKILL.md").read_text()
assert (s["t2"] / "beta").is_dir() and not (s["t2"] / "beta").is_symlink()
# migrate 镜像到 t1 的 beta 链已随基线还原而消失
assert not (s["t1"] / "beta").exists()
# settings 复原为迁移前内容
assert json.loads((claude / "settings.json").read_text()) == {"x": 1}
# 还原前的现状被移到旁边(可逆,未直接删)
assert list(s["home"].glob(".skill-library.before-restore-*")) or \
list((s["home"] / ".claude").glob("skills.before-restore-*"))
def test_restore_rolls_back_all_roots_when_installing_one_root_fails(
migrate_sandbox, monkeypatch):
s = migrate_sandbox
first = make_real_skill(s["t1"], "first", "BASE-FIRST")
second = make_real_skill(s["t2"], "second", "BASE-SECOND")
backup = L.make_backup([s["t1"], s["t2"]], s["lib"])
assert backup is not None
(first / "SKILL.md").write_text("CURRENT-FIRST", encoding="utf-8")
(second / "SKILL.md").write_text("CURRENT-SECOND", encoding="utf-8")
real_move = shutil.move
failed = {"done": False}
def fail_second_root(src, dst, *args, **kwargs):
if "targets/1" in str(src) and not failed["done"]:
failed["done"] = True
raise OSError("injected restore failure")
return real_move(src, dst, *args, **kwargs)
monkeypatch.setattr(shutil, "move", fail_second_root)
assert L.main(["restore", str(backup)]) == 1
assert (s["t1"] / "first" / "SKILL.md").read_text() == "CURRENT-FIRST"
assert (s["t2"] / "second" / "SKILL.md").read_text() == "CURRENT-SECOND"
def test_restore_staging_cannot_follow_precreated_symlink(
migrate_sandbox, tmp_path, monkeypatch):
s = migrate_sandbox
make_real_skill(s["t1"], "x", "BASE")
backup = L.make_backup([s["t1"], s["t2"]], s["lib"])
assert backup is not None
fixed = "2026-07-15T00:00:00+00:00"
monkeypatch.setattr(L, "now_iso", lambda: fixed)
token = fixed.replace(":", "").replace("-", "").replace(".", "")
predicted = backup.parent / f".restore-staging-{token}.{os.getpid()}"
outside = tmp_path / "outside"
outside.mkdir()
predicted.symlink_to(outside)
assert L.main(["restore", str(backup)]) == 0
assert not (outside / "targets").exists()
def test_restore_rejects_retargeted_symlink_ancestor_before_swapping(
migrate_sandbox, tmp_path):
s = migrate_sandbox
make_real_skill(s["t1"], "demo", "BASE")
backup = L.make_backup([s["t1"], s["t2"]], s["lib"])
assert backup is not None
shutil.rmtree(s["t1"].parent)
external = tmp_path / "external-claude"
foreign = make_real_skill(external / "skills", "foreign", "FOREIGN")
before = (foreign / "SKILL.md").read_bytes()
s["t1"].parent.symlink_to(external)
assert L.main(["restore", str(backup)]) == 1
assert (foreign / "SKILL.md").read_bytes() == before
assert not (external / "skills" / "demo").exists()
assert not list(external.glob("skills.before-restore-*"))
def test_restore_rejects_missing_manifest_root_before_swapping(migrate_sandbox):
"""manifest 标记 existed=true 的 root 缺档时,任何当前 root 都不得被换走。"""
import tarfile as _t
s = migrate_sandbox
first = make_real_skill(s["t1"], "first", "BASE-FIRST")
second = make_real_skill(s["t2"], "second", "BASE-SECOND")
backup = L.make_backup([s["t1"], s["t2"]], s["lib"])
assert backup is not None
broken = s["home"] / "missing-target-root.tar.gz"
with _t.open(backup, "r:gz") as source, _t.open(broken, "w:gz") as dest:
for member in source.getmembers():
if member.name == "targets/1" or member.name.startswith("targets/1/"):
continue
dest.addfile(member, source.extractfile(member) if member.isfile() else None)
(first / "SKILL.md").write_text("CURRENT-FIRST", encoding="utf-8")
(second / "SKILL.md").write_text("CURRENT-SECOND", encoding="utf-8")
assert L.main(["restore", str(broken)]) == 1
assert (first / "SKILL.md").read_text() == "CURRENT-FIRST"
assert (second / "SKILL.md").read_text() == "CURRENT-SECOND"
assert not list((s["home"] / ".claude").glob("skills.before-restore-*"))
assert not list((s["home"] / ".agents").glob("skills.before-restore-*"))
def test_restore_rejects_foreign_archive(migrate_sandbox, tmp_path):
"""没有 MANIFEST.json 的归档不是本工具的备份,restore 拒绝。"""
import tarfile as _t
s = migrate_sandbox
bogus = s["home"] / "bogus.tar.gz"
(tmp_path / "f.txt").write_text("hi", encoding="utf-8")
with _t.open(bogus, "w:gz") as tar:
tar.add(tmp_path / "f.txt", arcname="f.txt")
assert L.main(["restore", str(bogus)]) == 1
# ── 第 7 轮:事务性(apply 回滚 / add·remove 回滚 preset)───────────────
def test_apply_plan_rolls_back_on_failure(sandbox, monkeypatch):
"""apply_plan 中途建链失败 → 已建的链全部回滚,恢复到调用前。"""
lib, (t1, t2) = sandbox["lib"], sandbox["targets"]
make_skill(lib, "x", "x")
make_skill(lib, "y", "y")
plan = L.plan_selection({"x", "y"}, lib, sandbox["targets"], "test")
real = L.make_link
calls = []
def flaky(link, lib_, name):
calls.append(name)
if len(calls) == 3: # 第 3 次建链时炸(此时 t1 两条已建)
raise OSError("boom")
return real(link, lib_, name)
monkeypatch.setattr(L, "make_link", flaky)
with pytest.raises(OSError):
L.apply_plan(plan, lib, sandbox["targets"])
for t in (t1, t2): # 全部回滚,无残留半套链接
assert not (t / "x").exists()
assert not (t / "y").exists()
def test_apply_failure_rolls_back_to_prior_links(sandbox, monkeypatch):
"""从非空基线切换选择失败 → 回滚恢复旧选择的链接。"""
lib, (t1, t2) = sandbox["lib"], sandbox["targets"]
targets = sandbox["targets"]
make_skill(lib, "x", "x")
make_skill(lib, "y", "y")
L.apply_plan(L.plan_selection({"x"}, lib, targets, "test"), lib, targets)
plan_y = L.plan_selection({"y"}, lib, targets, "test")
def boom(*a, **k):
raise OSError("disk full")
monkeypatch.setattr(L, "make_link", boom)
with pytest.raises(OSError):
L.apply_plan(plan_y, lib, targets)
assert (t1 / "x").is_symlink() # 旧链还在(回滚)
assert not (t1 / "y").exists()
# ── 第 7 轮:make_link 不clobber库外软链 / migrate 拒逃逸相对软链 ─────────
def test_make_link_never_clobbers_foreign_symlink(sandbox):
"""slot 被库外软链占用时(含大小写折叠场景),make_link 绝不替换它。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
make_skill(lib, "x", "x")
outside = lib.parent / "outside"
outside.mkdir()
(t1 / "x").symlink_to(outside) # 库外软链占住 x 名
L.make_link(t1 / "x", lib, "x")
assert (t1 / "x").is_symlink()
assert os.readlink(t1 / "x") == str(outside) # 未被替换
def test_migrate_skips_skill_with_escaping_relative_symlink(migrate_sandbox):
"""含 ../../shared 这类逃逸相对软链的 skill:不搬入库,原地保留。"""
s = migrate_sandbox
d = make_real_skill(s["t1"], "x", "x")
(d / "shared").symlink_to("../../shared") # 逃逸到 skill 目录之外
assert L.main(["migrate", "--assume-skip"]) == 0
assert "x" not in L.library_skills(s["lib"])
assert (s["t1"] / "x").is_dir()
def test_internal_relative_symlink_not_flagged(sandbox):
"""指向 skill 内部的相对软链不算逃逸(搬动后仍有效)。"""
lib = sandbox["lib"]
d = make_skill(lib, "x", "x")
(d / "sub").mkdir()
(d / "sub" / "a.txt").write_text("hi", encoding="utf-8")
(d / "link").symlink_to("sub/a.txt") # 指向内部
assert L._escaping_relative_symlink(d) is None
# ══ 第 8 轮:按冻结清单逐项回归 ══════════════════════════════════════════
# 冻结 2 / 5:foreign shadow 不得被标记为已物化
def test_foreign_symlink_shadow_blocks_apply(sandbox, capsys):
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
targets = sandbox["targets"]
make_skill(lib, "x", "x")
outside = lib.parent / "ext"
outside.mkdir()
(t1 / "x").symlink_to(outside) # 库外软链遮蔽 x
plan = L.plan_selection({"x"}, lib, targets, "test")
assert plan["per_target"][str(t1)]["shadowed"] == ["x"]
with pytest.raises(L.PlanError, match="遮蔽"):
L.apply_plan(plan, lib, targets)
assert (t1 / "x").is_symlink() and os.readlink(t1 / "x") == str(outside)
def test_apfs_casefold_foreign_shadow_blocks_apply(sandbox):
"""APFS 上已有 foreign `X` 时,selected `x` 不得误报成功。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
targets = sandbox["targets"]
make_skill(lib, "x", "x")
outside = lib.parent / "ext"
outside.mkdir()
(t1 / "X").symlink_to(outside)
if not (t1 / "x").is_symlink():
pytest.skip("当前文件系统区分大小写")
before_inode = (t1 / "X").lstat().st_ino
plan = L.plan_selection({"x"}, lib, targets, "test")
assert plan["per_target"][str(t1)]["shadowed"] == ["x"]
with pytest.raises(L.PlanError, match="遮蔽"):
L.apply_plan(plan, lib, targets)
entry = next(t1.iterdir())
assert entry.name == "X"
assert entry.lstat().st_ino == before_inode
assert os.readlink(entry) == str(outside)
def test_shadowed_file_plan_reports_without_mutation(sandbox):
"""普通文件占位也算遮蔽;plan 只展示不改动,apply 拒绝且不碰占位文件。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
targets = sandbox["targets"]
make_skill(lib, "x", "x")
blocker = t1 / "x"
blocker.write_text("KEEP", encoding="utf-8")
plan = L.plan_selection({"x"}, lib, targets, "test")
assert plan["per_target"][str(t1)]["shadowed"] == ["x"]
assert blocker.read_text(encoding="utf-8") == "KEEP"
with pytest.raises(L.PlanError, match="遮蔽"):
L.apply_plan(plan, lib, targets)
assert blocker.read_text(encoding="utf-8") == "KEEP"
def test_make_link_returns_false_on_foreign(sandbox):
"""make_link 遇库外软链不建链、返回 False(apply 据此不记账、不误回滚)。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
make_skill(lib, "x", "x")
make_skill(lib, "y", "y")
outside = lib.parent / "ext"
outside.mkdir()
(t1 / "x").symlink_to(outside)
assert L.make_link(t1 / "x", lib, "x") is False
assert os.readlink(t1 / "x") == str(outside)
assert L.make_link(t1 / "y", lib, "y") is True
def test_make_link_create_race_never_overwrites_foreign_file(sandbox, monkeypatch):
"""槽位在检查后被外部文件抢占时,绝不能用 replace 覆盖。"""
lib, (t1, _) = sandbox["lib"], sandbox["targets"]
make_skill(lib, "x", "x")
slot = t1 / "x"
real_symlink = os.symlink
injected = {"done": False}
def race_symlink(src, dst, *args, **kwargs):
if Path(dst) == slot and not injected["done"]:
injected["done"] = True
slot.write_text("KEEP", encoding="utf-8")
return real_symlink(src, dst, *args, **kwargs)
monkeypatch.setattr(os, "symlink", race_symlink)
with pytest.raises(FileExistsError):
L.make_link(slot, lib, "x")
assert slot.is_file() and slot.read_text(encoding="utf-8") == "KEEP"
# 冻结 5:折叠 YAML description 不再被严重少算
def test_extract_description_folds_block_scalar_with_colons(sandbox):
lib = sandbox["lib"]
d = lib / "x"
d.mkdir()
(d / "SKILL.md").write_text(
"---\nname: x\ndescription: >\n"
" Use when: alpha happens. Also when: beta occurs.\n"
" Second line: gamma delta epsilon.\n"
"---\n# x\n", encoding="utf-8")
desc = L.extract_description(d)
assert "gamma delta epsilon" in desc # 含冒号的续行不再早停
assert len(desc) > 40 # 不再缩成两字符
# 冻结 6:恢复保真(绝对软链 + 目录权限)+ 拒伪造 manifest
def test_safe_extract_preserves_abs_symlink_and_dir_mode(tmp_path):
import tarfile
src = tmp_path / "src"
src.mkdir()
(src / "d").mkdir()
os.chmod(src / "d", 0o700)
(src / "abslink").symlink_to("/etc/hosts")
arc = tmp_path / "a.tar.gz"
with tarfile.open(arc, "w:gz", dereference=False) as tar:
tar.add(src, arcname="root")
dest = tmp_path / "out"
dest.mkdir()
with tarfile.open(arc, "r:gz") as tar:
L._safe_extract_all(tar, dest)
assert (dest / "root" / "abslink").is_symlink()
assert os.readlink(dest / "root" / "abslink") == "/etc/hosts"
assert (dest / "root" / "d").stat().st_mode & 0o777 == 0o700
def test_safe_extract_rejects_member_below_symlink_ancestor(tmp_path):
"""归档先建绝对软链后,不得再沿该祖先把后续成员写出 staging。"""
import tarfile
victim = tmp_path / "victim"
victim.mkdir()
arc = tmp_path / "escape.tar.gz"
with tarfile.open(arc, "w:gz") as tar:
root = tarfile.TarInfo("root")
root.type = tarfile.DIRTYPE