use super::*; use std::os::unix::fs::{PermissionsExt, symlink}; use std::process::Output; struct Project { temporary: TemporaryDirectory, layout: Layout, root: PathBuf, key: Ed25519KeyPair, } impl Project { fn new() -> Self { let temporary = TemporaryDirectory::new(); let layout = Layout::new(temporary.path().join("machine")); layout.initialize().unwrap(); let key = signing_key(); fs::write( layout.state().join("trusted-release-key"), format!("{}\n", hex(key.public_key().as_ref())), ) .unwrap(); write_release( &layout, &key, "1.0.0", &temporary.path().join("dispatch"), true, true, ); MachineManager::new(layout.clone()) .install_requested("1.0.0") .unwrap(); fs::copy(env!("CARGO_BIN_EXE_dever-launcher"), layout.bin().join("dever")).unwrap(); let root = temporary.path().join("project"); fs::create_dir_all(root.join("module")).unwrap(); fs::create_dir(root.join("config")).unwrap(); fs::write(root.join("config/setting.json"), "{}").unwrap(); Self { temporary, layout, root, key, } } fn run(&self, root: &Path, extra: &[&str]) -> Output { Command::new(self.layout.bin().join("dever")) .args(["skill", "install"]) .args(extra) .current_dir(root) .env_clear() .output() .unwrap() } fn succeeds(&self) { let output = self.run(&self.root, &[]); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert!(String::from_utf8_lossy(&output.stdout).contains("AGENTS.md")); self.assert_no_staging(); } fn fails(&self, message: &str) { let output = self.run(&self.root, &[]); assert!(!output.status.success()); assert!( String::from_utf8_lossy(&output.stderr).contains(message), "{}", String::from_utf8_lossy(&output.stderr) ); self.assert_no_staging(); } fn assert_no_staging(&self) { assert!(fs::read_dir(&self.root).unwrap().all(|entry| { !entry .unwrap() .file_name() .to_string_lossy() .starts_with(".dever-skill-") })); } fn sign_manifest(&self, manifest: &ReleaseManifest) { let release = self.layout.versions().join("1.0.0"); let bytes = serde_json::to_vec(manifest).unwrap(); fs::write(release.join("manifest.json"), &bytes).unwrap(); fs::write( release.join("manifest.sig"), hex(self.key.sign(&bytes).as_ref()), ) .unwrap(); } } #[test] fn project_skill_preserves_user_bytes_permissions_and_updates_only_its_block() { let fixture = Project::new(); let agents = fixture.root.join("AGENTS.md"); let before = "# 项目规则\r\n保留用户内容,无末尾换行"; fs::write(&agents, before).unwrap(); fs::set_permissions(&agents, fs::Permissions::from_mode(0o640)).unwrap(); fixture.succeeds(); assert_eq!( fs::read_to_string(&agents).unwrap(), format!("{before}\n\n{}", project_rules("1.0.0")) ); assert_eq!( fs::metadata(&agents).unwrap().permissions().mode() & 0o777, 0o640 ); let installed = fs::read(&agents).unwrap(); fixture.succeeds(); assert_eq!(fs::read(&agents).unwrap(), installed); let prefix = "# 自定义前文\r\n\r\n"; let suffix = "\r\n# 自定义后文\r\n原样保留"; fs::write( &agents, format!("{prefix}\n旧规则\n{suffix}"), ) .unwrap(); fixture.succeeds(); assert_eq!( fs::read_to_string(&agents).unwrap(), format!("{prefix}{}{suffix}", project_rules("1.0.0").trim_end()) ); } #[test] fn project_skill_rejects_ambiguous_or_unsafe_agents_without_changes() { let fixture = Project::new(); let agents = fixture.root.join("AGENTS.md"); for content in [ "", "", "", "", "", ] { fs::write(&agents, content).unwrap(); fixture.fails("exactly one complete"); assert_eq!(fs::read_to_string(&agents).unwrap(), content); } fs::write(&agents, [0xff, 0xfe]).unwrap(); fixture.fails("UTF-8"); assert_eq!(fs::read(&agents).unwrap(), [0xff, 0xfe]); fs::write(&agents, "do not overwrite").unwrap(); fs::set_permissions(&agents, fs::Permissions::from_mode(0o444)).unwrap(); fixture.fails("read-only"); fs::set_permissions(&agents, fs::Permissions::from_mode(0o644)).unwrap(); fs::remove_file(&agents).unwrap(); fs::create_dir(&agents).unwrap(); fixture.fails("regular file"); fs::remove_dir(&agents).unwrap(); let other = fixture.temporary.path().join("other-agents"); fs::write(&other, "untouched").unwrap(); symlink(&other, &agents).unwrap(); fixture.fails("regular file"); assert!(fs::symlink_metadata(&agents).unwrap().is_symlink()); assert_eq!(fs::read_to_string(&other).unwrap(), "untouched"); } #[test] fn project_skill_requires_project_root_valid_version_and_signed_template() { let fixture = Project::new(); let agents = fixture.root.join("AGENTS.md"); let output = fixture.run(&fixture.root, &["unwanted-directory"]); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("accepts no arguments")); assert!(!fixture.root.join("unwanted-directory").exists()); let output = fixture.run(fixture.temporary.path(), &[]); assert!(!output.status.success()); assert!(!fixture.temporary.path().join("AGENTS.md").exists()); fs::remove_dir(fixture.root.join("module")).unwrap(); symlink(fixture.temporary.path(), fixture.root.join("module")).unwrap(); fixture.fails("real module/"); fs::remove_file(fixture.root.join("module")).unwrap(); fs::create_dir(fixture.root.join("module")).unwrap(); let settings = fixture.root.join("config/setting.json"); for invalid in [ "not json", "[]", r#"{"dever":{"version":"latest"}}"#, r#"{"dever":{"version":"9.0.0"}}"#, ] { fs::write(&settings, invalid).unwrap(); let output = fixture.run(&fixture.root, &[]); assert!(!output.status.success()); assert!(!agents.exists()); } fs::remove_file(&settings).unwrap(); fixture.fails("cannot read"); fs::write(&settings, "{}").unwrap(); let template = fixture .layout .versions() .join("1.0.0/skills/dever-language/assets/template/AGENTS.md"); fs::write(&template, "tampered instructions").unwrap(); let output = fixture.run(&fixture.root, &[]); assert!(!output.status.success()); assert!(!agents.exists()); fixture.assert_no_staging(); } #[test] fn project_skill_refuses_unsigned_extra_or_invalid_signed_template() { let fixture = Project::new(); let agents = fixture.root.join("AGENTS.md"); fs::write(&agents, "user-owned instructions").unwrap(); let release = fixture.layout.versions().join("1.0.0"); let mut manifest: ReleaseManifest = serde_json::from_slice(&fs::read(release.join("manifest.json")).unwrap()).unwrap(); let logical = "skills/dever-language/assets/template/AGENTS.md"; manifest.artifacts.retain(|entry| entry.path != logical); fixture.sign_manifest(&manifest); fixture.fails("does not declare resource"); assert_eq!( fs::read_to_string(&agents).unwrap(), "user-owned instructions" ); let template = release.join(logical); for invalid in [ "no managed block", "missing end", "outside\nrules", ] { fs::write(&template, invalid).unwrap(); manifest.artifacts.retain(|entry| entry.path != logical); manifest.artifacts.push(artifact(&template, logical)); fixture.sign_manifest(&manifest); let output = fixture.run(&fixture.root, &[]); assert!(!output.status.success()); assert_eq!( fs::read_to_string(&agents).unwrap(), "user-owned instructions" ); fixture.assert_no_staging(); } }