Skip to main content

nxd_core/workspace/
source.rs

1use crate::config;
2use crate::process::Logger;
3use std::fs;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::process::Stdio;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9mod staging;
10
11pub use staging::{
12	ShortLivedTempDir, nix_store_add, stage_host_installer_input, stage_hosts_installer_input,
13};
14
15fn snapshot_git_files(source: &Path, destination: &Path) -> Result<(), Box<dyn std::error::Error>> {
16	let files = crate::process::sanitized_command("git")
17		.args(["ls-files", "-z", "--cached", "--others", "--exclude-standard"])
18		.current_dir(source)
19		.output()?;
20	if !files.status.success() {
21		return Err("Failed to list tracked and untracked Git source files.".into());
22	}
23
24	// Skip deleted tracked paths still listed by `git ls-files` until the index
25	// is updated (dirty working trees mid-refactor).
26	let mut present = Vec::new();
27	for relative in files.stdout.split(|&byte| byte == 0) {
28		if relative.is_empty() {
29			continue;
30		}
31		let path = source.join(std::str::from_utf8(relative)?);
32		if path.exists() {
33			present.extend_from_slice(relative);
34			present.push(0);
35		}
36	}
37
38	let mut rsync = crate::process::sanitized_command("rsync")
39		.args(["-a", "--from0", "--files-from=-", "./"])
40		.arg(format!("{}/", destination.display()))
41		.current_dir(source)
42		.stdin(Stdio::piped())
43		.spawn()?;
44	rsync.stdin.take().ok_or("Failed to open rsync input")?.write_all(&present)?;
45	if !rsync.wait()?.success() {
46		return Err("Failed to snapshot tracked and untracked Git source files.".into());
47	}
48	Ok(())
49}
50
51fn metadata_source_cache_root() -> PathBuf {
52	let cache_home = std::env::var("XDG_CACHE_HOME").map(PathBuf::from).unwrap_or_else(|_| {
53		let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
54		PathBuf::from(home).join(".cache")
55	});
56	cache_home.join(config::APP_NAME).join("metadata-sources").join("v1")
57}
58
59fn git_output(source: &Path, args: &[&str]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
60	let output = crate::process::sanitized_command("git")
61		.args(args)
62		.current_dir(source)
63		.output()
64		.map_err(|error| format!("Failed to inspect Git metadata: {error}"))?;
65	if !output.status.success() {
66		return Err(
67			format!("Git metadata inspection failed: {}", String::from_utf8_lossy(&output.stderr).trim())
68				.into(),
69		);
70	}
71	Ok(output.stdout)
72}
73
74fn append_untracked_content(
75	source: &Path,
76	paths: &[u8],
77	fingerprint: &mut Vec<u8>,
78) -> Result<(), Box<dyn std::error::Error>> {
79	#[cfg(unix)]
80	{
81		use std::ffi::OsString;
82		use std::os::unix::ffi::OsStringExt;
83
84		for raw_path in paths.split(|byte| *byte == 0).filter(|path| !path.is_empty()) {
85			let relative = PathBuf::from(OsString::from_vec(raw_path.to_vec()));
86			if relative.is_absolute()
87				|| relative.components().any(|component| {
88					matches!(
89						component,
90						std::path::Component::ParentDir
91							| std::path::Component::RootDir
92							| std::path::Component::Prefix(_)
93					)
94				}) {
95				return Err("Git returned an unsafe untracked source path.".into());
96			}
97			let path = source.join(&relative);
98			let metadata = fs::symlink_metadata(&path)?;
99			fingerprint.extend_from_slice(&(raw_path.len() as u64).to_be_bytes());
100			fingerprint.extend_from_slice(raw_path);
101			if metadata.file_type().is_symlink() {
102				let target = fs::read_link(path)?;
103				let target = target.as_os_str().as_encoded_bytes();
104				fingerprint.extend_from_slice(&(target.len() as u64).to_be_bytes());
105				fingerprint.extend_from_slice(target);
106			} else if metadata.is_file() {
107				let content = fs::read(path)?;
108				fingerprint.extend_from_slice(&(content.len() as u64).to_be_bytes());
109				fingerprint.extend_from_slice(&content);
110			} else {
111				return Err("Git source contains an unsupported untracked file type.".into());
112			}
113		}
114		Ok(())
115	}
116	#[cfg(not(unix))]
117	{
118		let _ = (source, paths, fingerprint);
119		Err("Git metadata snapshots require a Unix platform.".into())
120	}
121}
122
123fn metadata_eval_source_ref_with_cache(
124	source: &Path,
125	cache_root: &Path,
126) -> Result<String, Box<dyn std::error::Error>> {
127	let head = git_output(source, &["rev-parse", "--verify", "HEAD"])?;
128	let diff = git_output(source, &["diff", "--binary", "--no-ext-diff", "HEAD", "--"])?;
129	let untracked = git_output(source, &["ls-files", "-z", "--others", "--exclude-standard"])?;
130
131	if diff.is_empty() && untracked.is_empty() {
132		let revision = String::from_utf8(head)?.trim().to_string();
133		return Ok(format!("git+file://{}?rev={revision}", source.display()));
134	}
135
136	let mut fingerprint = b"nxd-metadata-source-v1\0".to_vec();
137	fingerprint.extend_from_slice(&head);
138	fingerprint.extend_from_slice(&diff);
139	fingerprint.extend_from_slice(&untracked);
140	append_untracked_content(source, &untracked, &mut fingerprint)?;
141	let digest = crate::plan::sha256_hex_bytes(&fingerprint);
142	let cache_entry = cache_root.join(&digest);
143	let cached_source = cache_entry.join("source");
144	let complete = cache_entry.join(".complete");
145	if complete.is_file() && cached_source.join("flake.nix").is_file() {
146		return Ok(format!("path:{}", cached_source.display()));
147	}
148
149	fs::create_dir_all(cache_root)?;
150	#[cfg(unix)]
151	{
152		use std::os::unix::fs::PermissionsExt;
153		fs::set_permissions(cache_root, fs::Permissions::from_mode(0o700))?;
154	}
155
156	let temporary =
157		cache_root.join(format!(".{digest}-{}", crate::workspace::local::unique_suffix()));
158	let temporary_source = temporary.join("source");
159	fs::create_dir_all(&temporary_source)?;
160	snapshot_git_files(source, &temporary_source)?;
161	if !temporary_source.join("flake.nix").is_file() {
162		let _ = fs::remove_dir_all(&temporary);
163		return Err("Metadata source snapshot does not contain flake.nix.".into());
164	}
165	fs::write(temporary.join(".complete"), b"nxd-metadata-source-v1\n")?;
166
167	match fs::rename(&temporary, &cache_entry) {
168		Ok(()) => {}
169		Err(_) if complete.is_file() && cached_source.join("flake.nix").is_file() => {
170			let _ = fs::remove_dir_all(&temporary);
171		}
172		Err(error) => {
173			let _ = fs::remove_dir_all(&temporary);
174			return Err(format!("Failed to publish metadata source cache: {error}").into());
175		}
176	}
177
178	Ok(format!("path:{}", cached_source.display()))
179}
180
181pub fn metadata_eval_source_ref(source: &Path) -> Result<String, Box<dyn std::error::Error>> {
182	metadata_eval_source_ref_with_cache(source, &metadata_source_cache_root())
183}
184
185pub fn clean_stale_gc_roots(_logger: Logger) {
186	let state_home = std::env::var("XDG_STATE_HOME").map(PathBuf::from).unwrap_or_else(|_| {
187		let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
188		PathBuf::from(home).join(".local").join("state")
189	});
190	let gcroots_base = state_home.join(config::APP_NAME).join("gcroots");
191	if !gcroots_base.exists() {
192		return;
193	}
194
195	let Ok(entries) = fs::read_dir(&gcroots_base) else {
196		return;
197	};
198
199	let seven_days = 7 * 24 * 60 * 60;
200	let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
201
202	for entry in entries.flatten() {
203		let path = entry.path();
204		if path.is_dir() {
205			let marker = path.join("run.timestamp");
206			let mut is_stale = false;
207			if marker.exists() {
208				if fs::read_to_string(&marker)
209					.ok()
210					.and_then(|c| c.trim().parse::<u64>().ok())
211					.filter(|&timestamp| now > timestamp + seven_days)
212					.is_some()
213				{
214					is_stale = true;
215				}
216			} else if fs::metadata(&path)
217				.ok()
218				.and_then(|m| m.modified().ok())
219				.and_then(|t| SystemTime::now().duration_since(t).ok())
220				.filter(|duration| duration.as_secs() > seven_days)
221				.is_some()
222			{
223				is_stale = true;
224			}
225
226			if is_stale {
227				eprintln!("Cleaning up stale local GC root directory: {}", path.display());
228				let _ = fs::remove_dir_all(&path);
229			}
230		}
231	}
232}
233
234#[cfg(test)]
235mod tests;