nxd_core/workspace/
source.rs1use 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 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(|×tamp| 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;