@lamentis/naome 1.3.8 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
- use std::fs;
2
- use std::path::{Path, PathBuf};
1
+ use std::fs::{self, OpenOptions};
2
+ use std::io::Write;
3
+ use std::path::{Component, Path, PathBuf};
3
4
 
4
5
  use serde::{Deserialize, Serialize};
5
6
  use sha2::{Digest, Sha256};
@@ -49,7 +50,11 @@ impl QualityCache {
49
50
  }
50
51
 
51
52
  pub(crate) fn read(&self, path: &str, content_hash: &str) -> Option<FileAnalysis> {
52
- let entry = fs::read_to_string(self.entry_path(path, content_hash)).ok()?;
53
+ let cache_path = self.safe_entry_path(path, content_hash).ok()?;
54
+ if !regular_file_without_symlink(&cache_path) {
55
+ return None;
56
+ }
57
+ let entry = fs::read_to_string(cache_path).ok()?;
53
58
  let entry: CacheEntry = serde_json::from_str(&entry).ok()?;
54
59
  if entry.schema == CACHE_SCHEMA
55
60
  && entry.naome_version == env!("CARGO_PKG_VERSION")
@@ -70,10 +75,7 @@ impl QualityCache {
70
75
  content_hash: &str,
71
76
  analysis: &FileAnalysis,
72
77
  ) -> Result<(), NaomeError> {
73
- let cache_path = self.entry_path(path, content_hash);
74
- if let Some(parent) = cache_path.parent() {
75
- fs::create_dir_all(parent)?;
76
- }
78
+ let cache_path = self.safe_entry_path(path, content_hash)?;
77
79
  let entry = CacheEntry {
78
80
  schema: CACHE_SCHEMA.to_string(),
79
81
  naome_version: env!("CARGO_PKG_VERSION").to_string(),
@@ -84,31 +86,29 @@ impl QualityCache {
84
86
  analysis: analysis.clone(),
85
87
  };
86
88
  let content = format!("{}\n", serde_json::to_string(&entry)?);
87
- if fs::read_to_string(&cache_path).map_or(true, |current| current != content) {
88
- fs::write(cache_path, content)?;
89
- }
90
- Ok(())
89
+ write_cache_entry(&cache_path, &content)
91
90
  }
92
91
 
93
- fn entry_path(&self, path: &str, content_hash: &str) -> PathBuf {
94
- self.root.join(CACHE_RELATIVE_PATH).join(stable_key(&[
92
+ fn safe_entry_path(&self, path: &str, content_hash: &str) -> Result<PathBuf, NaomeError> {
93
+ let cache_root = safe_cache_root(&self.root, true)?;
94
+ Ok(cache_root.join(stable_key(&[
95
95
  env!("CARGO_PKG_VERSION"),
96
96
  &self.config_hash,
97
97
  ADAPTER_VERSION,
98
98
  path,
99
99
  content_hash,
100
- ]))
100
+ ])))
101
101
  }
102
102
  }
103
103
 
104
104
  pub fn quality_cache_status(root: &Path) -> Result<QualityCacheStatus, NaomeError> {
105
- let path = root.join(CACHE_RELATIVE_PATH);
105
+ let path = safe_cache_root(root, false)?;
106
106
  let mut entry_count = 0;
107
107
  let mut bytes = 0;
108
- if path.is_dir() {
108
+ if regular_directory_without_symlink(&path) {
109
109
  for entry in fs::read_dir(&path)? {
110
110
  let entry = entry?;
111
- let metadata = entry.metadata()?;
111
+ let metadata = fs::symlink_metadata(entry.path())?;
112
112
  if metadata.is_file() {
113
113
  entry_count += 1;
114
114
  bytes += metadata.len();
@@ -124,13 +124,116 @@ pub fn quality_cache_status(root: &Path) -> Result<QualityCacheStatus, NaomeErro
124
124
  }
125
125
 
126
126
  pub fn clear_quality_cache(root: &Path) -> Result<QualityCacheStatus, NaomeError> {
127
- let path = root.join(CACHE_RELATIVE_PATH);
128
- if path.exists() {
127
+ let path = safe_cache_root(root, false)?;
128
+ if regular_directory_without_symlink(&path) {
129
129
  fs::remove_dir_all(&path)?;
130
130
  }
131
131
  quality_cache_status(root)
132
132
  }
133
133
 
134
+ fn write_cache_entry(cache_path: &Path, content: &str) -> Result<(), NaomeError> {
135
+ validate_cache_entry_parent(cache_path)?;
136
+ match fs::symlink_metadata(cache_path) {
137
+ Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
138
+ return Err(NaomeError::new(format!(
139
+ "quality cache entry must be a regular file: {}",
140
+ cache_path.display()
141
+ )));
142
+ }
143
+ Ok(_) => {
144
+ if fs::read_to_string(cache_path).map_or(false, |current| current == content) {
145
+ return Ok(());
146
+ }
147
+ }
148
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
149
+ Err(error) => return Err(error.into()),
150
+ }
151
+
152
+ let temp_path = cache_path.with_extension(format!(
153
+ "tmp.{}.{}",
154
+ std::process::id(),
155
+ stable_key(&[content]).trim_end_matches(".json")
156
+ ));
157
+ validate_cache_entry_parent(&temp_path)?;
158
+ let mut temp = OpenOptions::new()
159
+ .write(true)
160
+ .create_new(true)
161
+ .open(&temp_path)?;
162
+ if let Err(error) = temp.write_all(content.as_bytes()) {
163
+ let _ = fs::remove_file(&temp_path);
164
+ return Err(error.into());
165
+ }
166
+ if let Err(error) = temp.sync_all() {
167
+ let _ = fs::remove_file(&temp_path);
168
+ return Err(error.into());
169
+ }
170
+ drop(temp);
171
+ validate_cache_entry_parent(cache_path)?;
172
+ if let Err(error) = fs::rename(&temp_path, cache_path) {
173
+ let _ = fs::remove_file(&temp_path);
174
+ return Err(error.into());
175
+ }
176
+ Ok(())
177
+ }
178
+
179
+ fn validate_cache_entry_parent(cache_path: &Path) -> Result<(), NaomeError> {
180
+ let Some(parent) = cache_path.parent() else {
181
+ return Err(NaomeError::new("quality cache entry path has no parent"));
182
+ };
183
+ if !regular_directory_without_symlink(parent) {
184
+ return Err(NaomeError::new(format!(
185
+ "quality cache path must be a regular directory without symlinks: {}",
186
+ parent.display()
187
+ )));
188
+ }
189
+ Ok(())
190
+ }
191
+
192
+ fn safe_cache_root(root: &Path, create: bool) -> Result<PathBuf, NaomeError> {
193
+ let mut current = root.to_path_buf();
194
+ for component in Path::new(CACHE_RELATIVE_PATH).components() {
195
+ let Component::Normal(component) = component else {
196
+ return Err(NaomeError::new(
197
+ "quality cache path must be repository-relative",
198
+ ));
199
+ };
200
+ current.push(component);
201
+ match fs::symlink_metadata(&current) {
202
+ Ok(metadata) if metadata.file_type().is_symlink() => {
203
+ return Err(NaomeError::new(format!(
204
+ "quality cache path must not contain symlinks: {}",
205
+ current.display()
206
+ )));
207
+ }
208
+ Ok(metadata) if metadata.is_dir() => {}
209
+ Ok(_) => {
210
+ return Err(NaomeError::new(format!(
211
+ "quality cache path component is not a directory: {}",
212
+ current.display()
213
+ )));
214
+ }
215
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound && create => {
216
+ fs::create_dir(&current)?;
217
+ }
218
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(current),
219
+ Err(error) => return Err(error.into()),
220
+ }
221
+ }
222
+ Ok(current)
223
+ }
224
+
225
+ fn regular_directory_without_symlink(path: &Path) -> bool {
226
+ fs::symlink_metadata(path)
227
+ .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
228
+ .unwrap_or(false)
229
+ }
230
+
231
+ fn regular_file_without_symlink(path: &Path) -> bool {
232
+ fs::symlink_metadata(path)
233
+ .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
234
+ .unwrap_or(false)
235
+ }
236
+
134
237
  pub(crate) fn content_hash(content: &str) -> String {
135
238
  stable_key(&[content])
136
239
  }
@@ -8,6 +8,8 @@ use super::{FileAnalysis, NormalizedLine, SymbolAnalysis};
8
8
  use crate::quality::cache::{content_hash, QualityCache};
9
9
  use normalize::{normalize_line, token_set};
10
10
 
11
+ use super::repo_paths::regular_repo_file_path;
12
+
11
13
  pub(super) fn analyze_repo_file(
12
14
  root: &Path,
13
15
  path: &str,
@@ -16,8 +18,8 @@ pub(super) fn analyze_repo_file(
16
18
  cache: &QualityCache,
17
19
  allow_cache: bool,
18
20
  ) -> Option<(FileAnalysis, bool)> {
19
- let full_path = root.join(path);
20
- if !full_path.is_file() || is_binary_extension(path) {
21
+ let full_path = regular_repo_file_path(root, path)?;
22
+ if is_binary_extension(path) {
21
23
  return None;
22
24
  }
23
25
  if allow_cache {
@@ -1,6 +1,6 @@
1
1
  use std::collections::{HashMap, HashSet};
2
2
  use std::fs;
3
- use std::path::Path;
3
+ use std::path::{Component, Path};
4
4
  use std::process::Command;
5
5
 
6
6
  use crate::models::NaomeError;
@@ -44,8 +44,10 @@ pub(super) fn added_lines_by_path(
44
44
  if !target_paths.contains(&path) {
45
45
  continue;
46
46
  }
47
- if let Ok(content) = fs::read_to_string(root.join(&path)) {
48
- added.insert(path, content.lines().count());
47
+ if let Some(file_path) = regular_repo_file_path(root, &path) {
48
+ if let Ok(content) = fs::read_to_string(file_path) {
49
+ added.insert(path, content.lines().count());
50
+ }
49
51
  }
50
52
  }
51
53
  Ok(added)
@@ -92,3 +94,25 @@ fn untracked_paths(root: &Path) -> Result<Vec<String>, NaomeError> {
92
94
  .map(|entry| String::from_utf8_lossy(entry).replace('\\', "/"))
93
95
  .collect())
94
96
  }
97
+
98
+ pub(super) fn regular_repo_file_path(root: &Path, path: &str) -> Option<std::path::PathBuf> {
99
+ let relative = Path::new(path);
100
+ if relative.is_absolute() {
101
+ return None;
102
+ }
103
+ let mut current = root.to_path_buf();
104
+ for component in relative.components() {
105
+ let Component::Normal(component) = component else {
106
+ return None;
107
+ };
108
+ current.push(component);
109
+ let metadata = fs::symlink_metadata(&current).ok()?;
110
+ if metadata.file_type().is_symlink() {
111
+ return None;
112
+ }
113
+ }
114
+ fs::symlink_metadata(&current)
115
+ .ok()
116
+ .filter(|metadata| metadata.is_file())
117
+ .map(|_| current)
118
+ }
@@ -9,7 +9,7 @@ use sha2::{Digest, Sha256};
9
9
 
10
10
  use crate::{git, models::NaomeError, paths};
11
11
  pub(crate) use repo_paths::collect_repo_paths;
12
- use repo_paths::{added_lines_by_path, tracked_blob_hashes};
12
+ use repo_paths::{added_lines_by_path, regular_repo_file_path, tracked_blob_hashes};
13
13
 
14
14
  use super::cache::QualityCache;
15
15
  use super::types::{
@@ -317,7 +317,10 @@ fn max_file_bytes(mode: QualityMode) -> u64 {
317
317
  }
318
318
 
319
319
  fn file_exceeds_budget(root: &Path, path: &str, mode: QualityMode) -> bool {
320
- fs::metadata(root.join(path)).map_or(false, |metadata| {
320
+ let Some(full_path) = regular_repo_file_path(root, path) else {
321
+ return false;
322
+ };
323
+ fs::symlink_metadata(full_path).map_or(false, |metadata| {
321
324
  metadata.is_file() && metadata.len() > max_file_bytes(mode)
322
325
  })
323
326
  }
@@ -23,8 +23,14 @@ pub(super) fn refresh_support_files(
23
23
  changed.push(path.to_string());
24
24
  }
25
25
  }
26
- if replace_native_integrity(root, integrity)? {
27
- changed.push(NAOME_COMMAND_PATH.to_string());
26
+ for path in [
27
+ NAOME_COMMAND_PATH,
28
+ HEALTH_CHECKER_PATH,
29
+ TASK_STATE_CHECKER_PATH,
30
+ ] {
31
+ if replace_native_integrity(root, path, integrity)? {
32
+ changed.push(path.to_string());
33
+ }
28
34
  }
29
35
  Ok(changed)
30
36
  }
@@ -76,12 +82,13 @@ fn render_expected_integrity_block(integrity: &BTreeMap<String, String>) -> Stri
76
82
 
77
83
  fn replace_native_integrity(
78
84
  root: &Path,
85
+ relative_path: &str,
79
86
  integrity: &BTreeMap<String, String>,
80
87
  ) -> Result<bool, NaomeError> {
81
88
  let Some(native_hash) = integrity.get(NATIVE_BINARY_PATH) else {
82
89
  return Ok(false);
83
90
  };
84
- let path = root.join(NAOME_COMMAND_PATH);
91
+ let path = root.join(relative_path);
85
92
  if !path.is_file() {
86
93
  return Ok(false);
87
94
  }
@@ -24,6 +24,11 @@ const MACHINE_OWNED_PATHS: &[&str] = &[
24
24
  "docs/naome/upgrade.md",
25
25
  ];
26
26
 
27
+ #[cfg(windows)]
28
+ const NATIVE_BINARY_PATH: &str = ".naome/bin/naome-rust.exe";
29
+ #[cfg(not(windows))]
30
+ const NATIVE_BINARY_PATH: &str = ".naome/bin/naome-rust";
31
+
27
32
  const PROJECT_OWNED_PATHS: &[&str] = &[
28
33
  ".naomeignore",
29
34
  ".naome/init-state.json",
@@ -78,6 +83,99 @@ fn rejects_drifted_machine_owned_files() {
78
83
  assert!(joined.contains("docs/naome/execution.md"));
79
84
  }
80
85
 
86
+ #[test]
87
+ fn accepts_native_decision_binary_with_manifest_ownership_and_integrity() {
88
+ let mut repo = HarnessFixture::new();
89
+ repo.install_native_decision_binary("native decision fixture\n");
90
+
91
+ let errors = validate_harness_health(repo.path(), options(repo.integrity.clone())).unwrap();
92
+
93
+ assert!(errors.is_empty(), "{errors:#?}");
94
+ }
95
+
96
+ #[test]
97
+ fn rejects_native_decision_binary_when_manifest_ownership_is_removed() {
98
+ let mut repo = HarnessFixture::new();
99
+ repo.install_native_decision_binary("native decision fixture\n");
100
+ repo.remove_native_manifest_entry();
101
+
102
+ let errors = validate_harness_health(repo.path(), options(repo.integrity.clone())).unwrap();
103
+ let joined = errors.join("\n");
104
+
105
+ assert!(
106
+ joined.contains(&format!(
107
+ ".naome/manifest.json machineOwned must include {NATIVE_BINARY_PATH}."
108
+ )),
109
+ "{joined}"
110
+ );
111
+ assert!(
112
+ joined.contains(&format!(
113
+ ".naome/manifest.json integrity missing {NATIVE_BINARY_PATH}."
114
+ )),
115
+ "{joined}"
116
+ );
117
+ }
118
+
119
+ #[test]
120
+ fn rejects_checker_declared_native_integrity_without_native_manifest_entry() {
121
+ let mut repo = HarnessFixture::new();
122
+ let checker_path = ".naome/bin/check-task-state.js";
123
+ let native_hash = format!("sha256:{}", "a".repeat(64));
124
+ repo.write(
125
+ checker_path,
126
+ &format!("const expectedNativeBinaryIntegrity = \"{native_hash}\";\n"),
127
+ );
128
+ repo.integrity.insert(
129
+ checker_path.to_string(),
130
+ format!(
131
+ "sha256:{}",
132
+ sha256("const expectedNativeBinaryIntegrity = \"sha256:generated\";\n")
133
+ ),
134
+ );
135
+ write_file(
136
+ repo.path(),
137
+ ".naome/manifest.json",
138
+ &pretty_json(manifest_fixture(&repo.integrity)),
139
+ );
140
+
141
+ let errors = validate_harness_health(repo.path(), options(repo.integrity.clone())).unwrap();
142
+ let joined = errors.join("\n");
143
+
144
+ assert!(
145
+ joined.contains(&format!(
146
+ ".naome/manifest.json machineOwned must include {NATIVE_BINARY_PATH}."
147
+ )),
148
+ "{joined}"
149
+ );
150
+ assert!(
151
+ joined.contains(&format!("{NATIVE_BINARY_PATH} is missing.")),
152
+ "{joined}"
153
+ );
154
+ }
155
+
156
+ #[test]
157
+ fn rejects_native_integrity_assignment_with_appended_code() {
158
+ let mut repo = HarnessFixture::new();
159
+ repo.install_native_decision_binary("native decision fixture\n");
160
+ let native_hash = repo.integrity.get(NATIVE_BINARY_PATH).unwrap().clone();
161
+ repo.write(
162
+ ".naome/bin/check-harness-health.js",
163
+ &format!("const expectedNativeBinaryIntegrity = \"{native_hash}\"; process.exit(0);\n"),
164
+ );
165
+
166
+ let errors = validate_harness_health(repo.path(), options(repo.integrity.clone())).unwrap();
167
+ let joined = errors.join("\n");
168
+
169
+ assert!(
170
+ joined.contains(".naome/bin/check-harness-health.js integrity mismatch"),
171
+ "{joined}"
172
+ );
173
+ assert!(
174
+ joined.contains(".naome/bin/check-harness-health.js native binary integrity does not match .naome/manifest.json."),
175
+ "{joined}"
176
+ );
177
+ }
178
+
81
179
  #[test]
82
180
  fn rejects_missing_archive_ignore_boundary() {
83
181
  let repo = HarnessFixture::new();
@@ -175,6 +273,53 @@ impl HarnessFixture {
175
273
  fn write(&self, relative_path: &str, content: &str) {
176
274
  write_file(&self.root, relative_path, content);
177
275
  }
276
+
277
+ fn install_native_decision_binary(&mut self, content: &str) {
278
+ let native_hash = format!("sha256:{}", sha256(content));
279
+ write_file(&self.root, NATIVE_BINARY_PATH, content);
280
+ self.integrity
281
+ .insert(NATIVE_BINARY_PATH.to_string(), native_hash.clone());
282
+
283
+ for relative_path in [
284
+ ".naome/bin/naome.js",
285
+ ".naome/bin/check-harness-health.js",
286
+ ".naome/bin/check-task-state.js",
287
+ ] {
288
+ let command_content =
289
+ format!("const expectedNativeBinaryIntegrity = \"{native_hash}\";\n");
290
+ let normalized_command_content =
291
+ "const expectedNativeBinaryIntegrity = \"sha256:generated\";\n";
292
+ write_file(&self.root, relative_path, &command_content);
293
+ self.integrity.insert(
294
+ relative_path.to_string(),
295
+ format!("sha256:{}", sha256(normalized_command_content)),
296
+ );
297
+ }
298
+
299
+ self.write_manifest_with_native_entry();
300
+ }
301
+
302
+ fn remove_native_manifest_entry(&self) {
303
+ let mut manifest = read_json_value(&self.root, ".naome/manifest.json");
304
+ manifest["machineOwned"]
305
+ .as_array_mut()
306
+ .unwrap()
307
+ .retain(|entry| entry.as_str() != Some(NATIVE_BINARY_PATH));
308
+ manifest["integrity"]
309
+ .as_object_mut()
310
+ .unwrap()
311
+ .remove(NATIVE_BINARY_PATH);
312
+ write_file(&self.root, ".naome/manifest.json", &pretty_json(manifest));
313
+ }
314
+
315
+ fn write_manifest_with_native_entry(&self) {
316
+ let mut manifest = manifest_fixture(&self.integrity);
317
+ manifest["machineOwned"]
318
+ .as_array_mut()
319
+ .unwrap()
320
+ .push(json!(NATIVE_BINARY_PATH));
321
+ write_file(&self.root, ".naome/manifest.json", &pretty_json(manifest));
322
+ }
178
323
  }
179
324
 
180
325
  fn manifest_fixture(integrity: &HashMap<String, String>) -> serde_json::Value {
@@ -217,6 +362,10 @@ fn machine_content(relative_path: &str) -> String {
217
362
  }
218
363
  }
219
364
 
365
+ fn read_json_value(root: &Path, relative_path: &str) -> serde_json::Value {
366
+ serde_json::from_str(&fs::read_to_string(root.join(relative_path)).unwrap()).unwrap()
367
+ }
368
+
220
369
  fn write_file(root: &Path, relative_path: &str, content: &str) {
221
370
  let path = root.join(relative_path);
222
371
  fs::create_dir_all(path.parent().unwrap()).unwrap();
@@ -4,8 +4,8 @@ use std::fs;
4
4
 
5
5
  use naome_core::{
6
6
  check_repository_quality, check_repository_quality_paths, check_semantic_legacy,
7
- init_repository_quality, init_repository_quality_with_mode, quality_cache_status,
8
- QualityInitMode, QualityMode,
7
+ clear_quality_cache, init_repository_quality, init_repository_quality_with_mode,
8
+ quality_cache_status, QualityInitMode, QualityMode,
9
9
  };
10
10
 
11
11
  use repo_support::TestRepo;
@@ -140,6 +140,67 @@ fn second_report_uses_file_analysis_cache() {
140
140
  assert_eq!(second.summary.cache_misses, 0);
141
141
  }
142
142
 
143
+ #[cfg(unix)]
144
+ #[test]
145
+ fn report_skips_repository_symlinks_without_caching_target_contents() {
146
+ let repo = quality_repo("quality-cache-skips-file-symlink");
147
+ let victim = repo.path().join("../naome-victim-secret.txt");
148
+ fs::write(&victim, "VALIDATION_SECRET_raw_lines_12345\n").unwrap();
149
+ std::os::unix::fs::symlink(&victim, repo.path().join("leak.txt")).unwrap();
150
+ repo.git(&["add", "leak.txt"]);
151
+ repo.git(&["commit", "-m", "tracked symlink"]);
152
+
153
+ let report = check_repository_quality(repo.path(), QualityMode::Report).unwrap();
154
+ let cache = quality_cache_status(repo.path()).unwrap();
155
+
156
+ assert!(!report.scanned_paths.contains(&"leak.txt".to_string()));
157
+ assert_eq!(cache.entry_count, 0);
158
+ }
159
+
160
+ #[cfg(unix)]
161
+ #[test]
162
+ fn path_budget_skips_symlinks_before_reading_target_metadata() {
163
+ let repo = quality_repo("quality-budget-skips-symlink");
164
+ let victim = repo.path().join("../naome-large-victim.txt");
165
+ fs::write(&victim, "x".repeat(1024 * 1024 + 1)).unwrap();
166
+ std::os::unix::fs::symlink(&victim, repo.path().join("large-link.txt")).unwrap();
167
+ repo.git(&["add", "large-link.txt"]);
168
+ repo.git(&["commit", "-m", "tracked large symlink"]);
169
+
170
+ let report = check_repository_quality_paths(repo.path(), &["large-link.txt"]).unwrap();
171
+
172
+ assert!(!report.scanned_paths.contains(&"large-link.txt".to_string()));
173
+ assert!(!report
174
+ .summary
175
+ .reason_codes
176
+ .contains(&"max_file_bytes".to_string()));
177
+ fs::remove_file(victim).unwrap();
178
+ }
179
+
180
+ #[cfg(unix)]
181
+ #[test]
182
+ fn cache_operations_reject_symlinked_cache_path_components() {
183
+ let repo = quality_repo("quality-cache-rejects-cache-symlink");
184
+ repo.write_file("src/a.js", "export const value = 1;\n");
185
+ repo.commit_all("baseline");
186
+ let outside = repo.path().join("../naome-outside-cache");
187
+ fs::create_dir_all(outside.join("quality")).unwrap();
188
+ fs::remove_dir_all(repo.path().join(".naome/cache")).ok();
189
+ std::os::unix::fs::symlink(&outside, repo.path().join(".naome/cache")).unwrap();
190
+
191
+ let report = check_repository_quality(repo.path(), QualityMode::Report).unwrap();
192
+ let clear_error = clear_quality_cache(repo.path()).unwrap_err();
193
+
194
+ assert_eq!(report.summary.cache_hits, 0);
195
+ assert!(outside.join("quality").is_dir());
196
+ assert!(!fs::read_dir(outside.join("quality"))
197
+ .unwrap()
198
+ .any(|entry| entry.is_ok()));
199
+ assert!(clear_error
200
+ .to_string()
201
+ .contains("must not contain symlinks"));
202
+ }
203
+
143
204
  #[test]
144
205
  fn report_budget_marks_truncated_reports() {
145
206
  let repo = quality_repo("quality-report-budget");
@@ -64,6 +64,44 @@ export function archiveUpgradePath(ctx, archiveDirName, relativePath) {
64
64
  return join(ctx.targetRoot, ".naome", "archive", archiveDirName, relativePath);
65
65
  }
66
66
 
67
+ export function assertWritableArchivePath(ctx, archiveDirName, relativePath) {
68
+ const archiveRelativePath = join(".naome", "archive", archiveDirName, relativePath);
69
+ const parts = archiveRelativePath.split(/[\\/]+/);
70
+ let current = ctx.targetRoot;
71
+
72
+ for (let index = 0; index < parts.length; index += 1) {
73
+ const part = parts[index];
74
+ const isLeaf = index === parts.length - 1;
75
+ current = join(current, part);
76
+
77
+ try {
78
+ const stats = lstatSync(current);
79
+ if (stats.isSymbolicLink()) {
80
+ printError(ctx, `NAOME cannot archive ${relativePath} safely.`);
81
+ console.error(`${archiveRelativePath} must not contain symbolic links.`);
82
+ process.exit(1);
83
+ }
84
+
85
+ if (!isLeaf && !stats.isDirectory()) {
86
+ printError(ctx, `NAOME cannot archive ${relativePath} because the archive path is not a directory.`);
87
+ console.error(`${join(...parts.slice(0, index + 1))} must be a regular directory or must not exist.`);
88
+ process.exit(1);
89
+ }
90
+
91
+ if (isLeaf && existsSync(current) && !stats.isFile()) {
92
+ printError(ctx, `NAOME cannot archive ${relativePath} because the archive path is not a file.`);
93
+ console.error(`${archiveRelativePath} must be a regular file or must not exist.`);
94
+ process.exit(1);
95
+ }
96
+ } catch (error) {
97
+ if (error.code === "ENOENT") {
98
+ continue;
99
+ }
100
+ throw error;
101
+ }
102
+ }
103
+ }
104
+
67
105
  export function ensureArchiveDirectory(ctx) {
68
106
  const archivePath = ".naome/archive";
69
107
  const targetPath = join(ctx.targetRoot, archivePath);
@@ -31,6 +31,8 @@ const legacyOptionalHookPaths = [
31
31
  "docs/naome/codex-hooks.md",
32
32
  ];
33
33
 
34
+ const trustedRetiredMachineOwnedPaths = new Set(legacyOptionalHookPaths);
35
+
34
36
  export async function runFreshInstall(ctx) {
35
37
  await confirmAgentsTakeover(ctx);
36
38
 
@@ -110,8 +112,11 @@ function removeRetiredMachineOwnedFiles(ctx, manifest, archiveDirName, options =
110
112
  ...ctx.localOnlyMachineOwnedPaths,
111
113
  ctx.nativeBinaryRelativePath,
112
114
  ].filter(Boolean));
115
+ const manifestRetiredPaths = Array.isArray(manifest?.machineOwned)
116
+ ? manifest.machineOwned.filter((path) => trustedRetiredMachineOwnedPaths.has(path))
117
+ : [];
113
118
  const retiredPaths = [
114
- ...(Array.isArray(manifest?.machineOwned) ? manifest.machineOwned : []),
119
+ ...manifestRetiredPaths,
115
120
  ...(Array.isArray(options.extraPaths) ? options.extraPaths : []),
116
121
  ];
117
122