1use super::plan::{InstallPlan, PlanAction, SymlinkLinkMode};
8use crate::managed_block::{CommentStyle as MbStyle, ManagedBlock};
9use std::fs;
10use std::io;
11use std::os::unix::fs as unix_fs;
12use std::path::{Path, PathBuf};
13use std::time::SystemTime;
14use thiserror::Error;
15
16#[derive(Debug, Error)]
17pub enum ApplyError {
18 #[error("io error at {path}: {source}")]
19 Io {
20 path: PathBuf,
21 #[source]
22 source: io::Error,
23 },
24 #[error("could not back up {dest} to {backup}: {source}")]
25 Backup {
26 dest: PathBuf,
27 backup: PathBuf,
28 #[source]
29 source: io::Error,
30 },
31 #[error("managed-block helper rejected entry `{entry_id}` for {config_file}: {source}")]
32 ManagedBlock {
33 entry_id: String,
34 config_file: PathBuf,
35 #[source]
36 source: crate::managed_block::ManagedBlockError,
37 },
38 #[error(
39 "tag `{value}` is not a trusted tag name (allowed: ASCII alphanumeric / `-` / `_`, non-empty)"
40 )]
41 InvalidTag { value: String },
42}
43
44pub fn is_trusted_tag(s: &str) -> bool {
51 !s.is_empty()
52 && s.chars()
53 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum AppliedChange {
61 SymlinkCreated {
62 entry_id: String,
63 dest: PathBuf,
64 source: PathBuf,
65 link_mode: SymlinkLinkMode,
66 },
67 SymlinkReplaced {
68 entry_id: String,
69 dest: PathBuf,
70 source: PathBuf,
71 link_mode: SymlinkLinkMode,
72 },
73 FileBackedUpThenSymlinked {
74 entry_id: String,
75 dest: PathBuf,
76 source: PathBuf,
77 link_mode: SymlinkLinkMode,
78 backup: PathBuf,
79 },
80 ManagedBlockApplied {
81 entry_id: String,
82 config_file: PathBuf,
83 },
84 NoOp {
85 entry_id: String,
86 dest: PathBuf,
87 },
88}
89
90#[derive(Debug, Clone, Copy)]
91pub enum Mode {
92 DryRun,
93 Apply,
94}
95
96pub fn run(
104 plan: &InstallPlan,
105 mode: Mode,
106 now: SystemTime,
107 tag: Option<&str>,
108) -> Result<Vec<AppliedChange>, ApplyError> {
109 if let Some(name) = tag
110 && !is_trusted_tag(name)
111 {
112 return Err(ApplyError::InvalidTag {
113 value: name.to_string(),
114 });
115 }
116 let backup_root = backup_root_for(plan, now);
117 let mut changes = Vec::with_capacity(plan.actions.len());
118 for action in &plan.actions {
119 let change = match action {
120 PlanAction::Symlink {
121 entry_id,
122 source,
123 dest,
124 link_mode,
125 requires_backup,
126 } => handle_symlink(
127 mode,
128 entry_id,
129 source,
130 dest,
131 *link_mode,
132 *requires_backup,
133 &backup_root,
134 )?,
135 PlanAction::ManagedBlock {
136 entry_id,
137 config_file,
138 surface,
139 comment_style,
140 body,
141 } => handle_managed_block(mode, entry_id, config_file, surface, *comment_style, body)?,
142 };
143 changes.push(change);
144 }
145
146 if let (Mode::Apply, Some(name)) = (mode, tag) {
151 let had_backup = changes
152 .iter()
153 .any(|c| matches!(c, AppliedChange::FileBackedUpThenSymlinked { .. }));
154 if had_backup {
155 let marker = backup_root.join(format!("tag-{name}"));
156 fs::create_dir_all(&backup_root).map_err(|source| ApplyError::Io {
157 path: backup_root.clone(),
158 source,
159 })?;
160 fs::write(&marker, b"").map_err(|source| ApplyError::Io {
161 path: marker,
162 source,
163 })?;
164 }
165 }
166 Ok(changes)
167}
168
169fn backup_root_for(plan: &InstallPlan, now: SystemTime) -> PathBuf {
170 let secs = now
171 .duration_since(SystemTime::UNIX_EPOCH)
172 .map(|d| d.as_secs())
173 .unwrap_or(0);
174 plan.state_home
175 .join("backups")
176 .join(&plan.product)
177 .join(format!("{secs}"))
178}
179
180fn handle_symlink(
181 mode: Mode,
182 entry_id: &str,
183 source: &Path,
184 dest: &Path,
185 link_mode: SymlinkLinkMode,
186 requires_backup_flag: bool,
187 backup_root: &Path,
188) -> Result<AppliedChange, ApplyError> {
189 let current = read_symlink_target(dest);
190 if current.as_deref() == Some(source) {
191 return Ok(AppliedChange::NoOp {
193 entry_id: entry_id.to_string(),
194 dest: dest.to_path_buf(),
195 });
196 }
197
198 if matches!(mode, Mode::DryRun) {
199 return Ok(classify_dry_run(
200 entry_id,
201 source,
202 dest,
203 link_mode,
204 requires_backup_flag,
205 backup_root,
206 ));
207 }
208
209 ensure_parent_dir(dest)?;
210
211 let meta = fs::symlink_metadata(dest);
212 match meta {
213 Ok(m) if m.file_type().is_symlink() => {
214 fs::remove_file(dest).map_err(|source| ApplyError::Io {
216 path: dest.to_path_buf(),
217 source,
218 })?;
219 unix_fs::symlink(source, dest).map_err(|source_err| ApplyError::Io {
220 path: dest.to_path_buf(),
221 source: source_err,
222 })?;
223 Ok(AppliedChange::SymlinkReplaced {
224 entry_id: entry_id.to_string(),
225 dest: dest.to_path_buf(),
226 source: source.to_path_buf(),
227 link_mode,
228 })
229 }
230 Ok(m) if m.file_type().is_file() => {
231 let backup = move_to_backup(dest, entry_id, backup_root)?;
233 unix_fs::symlink(source, dest).map_err(|source_err| ApplyError::Io {
234 path: dest.to_path_buf(),
235 source: source_err,
236 })?;
237 Ok(AppliedChange::FileBackedUpThenSymlinked {
238 entry_id: entry_id.to_string(),
239 dest: dest.to_path_buf(),
240 source: source.to_path_buf(),
241 link_mode,
242 backup,
243 })
244 }
245 Ok(m) => {
246 Err(ApplyError::Io {
249 path: dest.to_path_buf(),
250 source: io::Error::new(
251 io::ErrorKind::AlreadyExists,
252 format!(
253 "refusing to overwrite non-file destination (file_type={:?})",
254 m.file_type()
255 ),
256 ),
257 })
258 }
259 Err(e) if e.kind() == io::ErrorKind::NotFound => {
260 unix_fs::symlink(source, dest).map_err(|source_err| ApplyError::Io {
261 path: dest.to_path_buf(),
262 source: source_err,
263 })?;
264 Ok(AppliedChange::SymlinkCreated {
265 entry_id: entry_id.to_string(),
266 dest: dest.to_path_buf(),
267 source: source.to_path_buf(),
268 link_mode,
269 })
270 }
271 Err(e) => Err(ApplyError::Io {
272 path: dest.to_path_buf(),
273 source: e,
274 }),
275 }
276}
277
278fn classify_dry_run(
279 entry_id: &str,
280 source: &Path,
281 dest: &Path,
282 link_mode: SymlinkLinkMode,
283 requires_backup_flag: bool,
284 backup_root: &Path,
285) -> AppliedChange {
286 let meta = fs::symlink_metadata(dest);
287 match meta {
288 Ok(m) if m.file_type().is_symlink() => AppliedChange::SymlinkReplaced {
289 entry_id: entry_id.to_string(),
290 dest: dest.to_path_buf(),
291 source: source.to_path_buf(),
292 link_mode,
293 },
294 Ok(m) if m.file_type().is_file() => {
295 let backup = backup_root
296 .join(entry_id)
297 .join(dest.file_name().map(Path::new).unwrap_or(Path::new("file")));
298 AppliedChange::FileBackedUpThenSymlinked {
299 entry_id: entry_id.to_string(),
300 dest: dest.to_path_buf(),
301 source: source.to_path_buf(),
302 link_mode,
303 backup,
304 }
305 }
306 Ok(_) => AppliedChange::SymlinkReplaced {
307 entry_id: entry_id.to_string(),
308 dest: dest.to_path_buf(),
309 source: source.to_path_buf(),
310 link_mode,
311 },
312 Err(_) if requires_backup_flag => {
313 AppliedChange::SymlinkCreated {
316 entry_id: entry_id.to_string(),
317 dest: dest.to_path_buf(),
318 source: source.to_path_buf(),
319 link_mode,
320 }
321 }
322 Err(_) => AppliedChange::SymlinkCreated {
323 entry_id: entry_id.to_string(),
324 dest: dest.to_path_buf(),
325 source: source.to_path_buf(),
326 link_mode,
327 },
328 }
329}
330
331fn move_to_backup(dest: &Path, entry_id: &str, backup_root: &Path) -> Result<PathBuf, ApplyError> {
332 let file_name = dest.file_name().map(Path::new).unwrap_or(Path::new("file"));
333 let backup_dir = backup_root.join(entry_id);
334 fs::create_dir_all(&backup_dir).map_err(|source| ApplyError::Backup {
335 dest: dest.to_path_buf(),
336 backup: backup_dir.clone(),
337 source,
338 })?;
339 let backup_path = backup_dir.join(file_name);
340 fs::rename(dest, &backup_path).map_err(|source| ApplyError::Backup {
341 dest: dest.to_path_buf(),
342 backup: backup_path.clone(),
343 source,
344 })?;
345 Ok(backup_path)
346}
347
348fn ensure_parent_dir(dest: &Path) -> Result<(), ApplyError> {
349 if let Some(parent) = dest.parent() {
350 fs::create_dir_all(parent).map_err(|source| ApplyError::Io {
351 path: parent.to_path_buf(),
352 source,
353 })?;
354 }
355 Ok(())
356}
357
358fn read_symlink_target(p: &Path) -> Option<PathBuf> {
359 fs::read_link(p).ok()
360}
361
362fn handle_managed_block(
363 mode: Mode,
364 entry_id: &str,
365 config_file: &Path,
366 surface: &str,
367 comment_style: super::link_map::CommentStyle,
368 body: &str,
369) -> Result<AppliedChange, ApplyError> {
370 let helper_style = match comment_style {
371 super::link_map::CommentStyle::Hash => MbStyle::Hash,
372 super::link_map::CommentStyle::DoubleSlash => MbStyle::DoubleSlash,
373 };
374 let block = ManagedBlock::new(surface.to_string(), helper_style);
375
376 let existing = match fs::read_to_string(config_file) {
377 Ok(s) => s,
378 Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
379 Err(e) => {
380 return Err(ApplyError::Io {
381 path: config_file.to_path_buf(),
382 source: e,
383 });
384 }
385 };
386
387 let needs_force = block.read(&existing).map(|o| o.is_none()).unwrap_or(true);
389
390 if matches!(mode, Mode::DryRun) {
391 let projected = block
392 .write(&existing, body, needs_force)
393 .map_err(|source| ApplyError::ManagedBlock {
394 entry_id: entry_id.to_string(),
395 config_file: config_file.to_path_buf(),
396 source,
397 })?;
398 return Ok(if projected == existing {
399 AppliedChange::NoOp {
400 entry_id: entry_id.to_string(),
401 dest: config_file.to_path_buf(),
402 }
403 } else {
404 AppliedChange::ManagedBlockApplied {
405 entry_id: entry_id.to_string(),
406 config_file: config_file.to_path_buf(),
407 }
408 });
409 }
410
411 let new_content = block
412 .write(&existing, body, needs_force)
413 .map_err(|source| ApplyError::ManagedBlock {
414 entry_id: entry_id.to_string(),
415 config_file: config_file.to_path_buf(),
416 source,
417 })?;
418 if new_content == existing {
419 return Ok(AppliedChange::NoOp {
420 entry_id: entry_id.to_string(),
421 dest: config_file.to_path_buf(),
422 });
423 }
424 ensure_parent_dir(config_file)?;
425 fs::write(config_file, new_content).map_err(|source| ApplyError::Io {
426 path: config_file.to_path_buf(),
427 source,
428 })?;
429 Ok(AppliedChange::ManagedBlockApplied {
430 entry_id: entry_id.to_string(),
431 config_file: config_file.to_path_buf(),
432 })
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use crate::install::plan::{InstallPlan, SymlinkLinkMode};
439 use pretty_assertions::assert_eq;
440 use std::fs;
441 use tempfile::TempDir;
442
443 #[test]
444 fn trusted_tag_accepts_alnum_dash_underscore() {
445 assert!(is_trusted_tag("pre-bump"));
446 assert!(is_trusted_tag("rc_1"));
447 assert!(is_trusted_tag("0-2-0"));
448 assert!(is_trusted_tag("A1B2C3"));
449 }
450
451 #[test]
452 fn trusted_tag_rejects_empty_or_unsafe() {
453 assert!(!is_trusted_tag(""));
454 assert!(!is_trusted_tag("../escape"));
455 assert!(!is_trusted_tag("with space"));
456 assert!(!is_trusted_tag("dot.in.name"));
457 assert!(!is_trusted_tag("slash/in/name"));
458 assert!(!is_trusted_tag("null\0byte"));
459 }
460
461 #[test]
462 fn run_rejects_untrusted_tag_before_walking_actions() {
463 let tmp = TempDir::new().unwrap();
467 let plan = InstallPlan {
468 product: "claude".to_string(),
469 source_root: tmp.path().to_path_buf(),
470 home: tmp.path().join("home"),
471 state_home: tmp.path().join("state"),
472 actions: Vec::new(),
473 };
474 let err = run(
475 &plan,
476 Mode::Apply,
477 SystemTime::UNIX_EPOCH,
478 Some("../escape"),
479 )
480 .unwrap_err();
481 match err {
482 ApplyError::InvalidTag { value } => assert_eq!(value, "../escape"),
483 other => panic!("expected InvalidTag, got {other:?}"),
484 }
485 }
486
487 fn directory_symlink_plan(tmp: &TempDir) -> (InstallPlan, PathBuf, PathBuf) {
488 let source = tmp.path().join("source/skills/reporting/daily-brief");
489 fs::create_dir_all(&source).unwrap();
490 fs::write(source.join("SKILL.md"), "# daily brief\n").unwrap();
491 let dest = tmp.path().join("home/skills/reporting/daily-brief");
492 let plan = InstallPlan {
493 product: "codex".to_string(),
494 source_root: tmp.path().join("source"),
495 home: tmp.path().join("home"),
496 state_home: tmp.path().join("state"),
497 actions: vec![PlanAction::Symlink {
498 entry_id: "reporting.daily-brief".to_string(),
499 source: source.clone(),
500 dest: dest.clone(),
501 link_mode: SymlinkLinkMode::Directory,
502 requires_backup: false,
503 }],
504 };
505 (plan, source, dest)
506 }
507
508 #[test]
509 fn apply_creates_directory_symlink_and_second_apply_is_noop() {
510 let tmp = TempDir::new().unwrap();
511 let (plan, source, dest) = directory_symlink_plan(&tmp);
512
513 let first = run(&plan, Mode::Apply, SystemTime::UNIX_EPOCH, None).unwrap();
514
515 assert_eq!(
516 first,
517 vec![AppliedChange::SymlinkCreated {
518 entry_id: "reporting.daily-brief".to_string(),
519 dest: dest.clone(),
520 source: source.clone(),
521 link_mode: SymlinkLinkMode::Directory,
522 }]
523 );
524 assert!(
525 fs::symlink_metadata(&dest)
526 .unwrap()
527 .file_type()
528 .is_symlink()
529 );
530 assert_eq!(fs::read_link(&dest).unwrap(), source);
531
532 let second = run(&plan, Mode::Apply, SystemTime::UNIX_EPOCH, None).unwrap();
533
534 assert_eq!(
535 second,
536 vec![AppliedChange::NoOp {
537 entry_id: "reporting.daily-brief".to_string(),
538 dest,
539 }]
540 );
541 }
542
543 #[test]
544 fn apply_refuses_to_overwrite_existing_real_directory() {
545 let tmp = TempDir::new().unwrap();
546 let (plan, _source, dest) = directory_symlink_plan(&tmp);
547 fs::create_dir_all(&dest).unwrap();
548
549 let err = run(&plan, Mode::Apply, SystemTime::UNIX_EPOCH, None).unwrap_err();
550
551 match err {
552 ApplyError::Io { path, source } => {
553 assert_eq!(path, dest);
554 assert_eq!(source.kind(), io::ErrorKind::AlreadyExists);
555 }
556 other => panic!("expected AlreadyExists io error, got {other:?}"),
557 }
558 }
559}