lean_ctx/core/context_package/
migration.rs1use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use super::verify::verify_package_file;
12
13pub(crate) const PACKAGE_MIGRATION_RECEIPT_VERSION: u32 = 1;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct PackageMigrationReceipt {
18 pub receipt_version: u32,
19 pub source_file: String,
20 pub output_file: String,
21 pub source_sha256: String,
22 pub output_sha256: String,
23 pub byte_preserving: bool,
24 pub source_retained_for_rollback: bool,
25}
26
27pub fn rollback_legacy_package(receipt_path: &Path) -> Result<PathBuf, String> {
28 let _lock = lock_package_migration(receipt_path)?;
29 reject_symlink(receipt_path, "migration receipt")?;
30 let receipt_bytes =
31 std::fs::read(receipt_path).map_err(|error| format!("read migration receipt: {error}"))?;
32 let receipt: PackageMigrationReceipt = serde_json::from_slice(&receipt_bytes)
33 .map_err(|error| format!("parse migration receipt: {error}"))?;
34 if receipt.receipt_version != PACKAGE_MIGRATION_RECEIPT_VERSION
35 || !receipt.byte_preserving
36 || !receipt.source_retained_for_rollback
37 || receipt.source_sha256 != receipt.output_sha256
38 {
39 return Err("migration receipt is not rollback-compatible".to_string());
40 }
41 validate_plain_filename(&receipt.source_file, "source_file")?;
42 validate_plain_filename(&receipt.output_file, "output_file")?;
43 let parent = receipt_path
44 .parent()
45 .ok_or_else(|| "migration receipt has no parent directory".to_string())?;
46 let source = parent.join(&receipt.source_file);
47 let output = parent.join(&receipt.output_file);
48 if source == output
49 || source.extension().and_then(|value| value.to_str())
50 != Some(crate::core::contracts::LEGACY_PACKAGE_EXTENSION)
51 || output.extension().and_then(|value| value.to_str())
52 != Some(crate::core::contracts::PACKAGE_EXTENSION)
53 || source.with_extension(crate::core::contracts::PACKAGE_EXTENSION) != output
54 {
55 return Err("migration receipt source/output package binding is invalid".to_string());
56 }
57 if output.with_extension("ctxpkg.migration.json") != receipt_path {
58 return Err("migration receipt path does not match output package".to_string());
59 }
60 reject_symlink(&source, "rollback source")?;
61 reject_symlink(&output, "rollback output")?;
62 validate_digest(&source, &receipt.source_sha256, "rollback source")?;
63 if output.exists() {
64 validate_digest(&output, &receipt.output_sha256, "rollback output")?;
65 std::fs::remove_file(&output)
66 .map_err(|error| format!("remove migrated package {}: {error}", output.display()))?;
67 sync_parent(parent)?;
68 }
69 std::fs::remove_file(receipt_path)
70 .map_err(|error| format!("remove migration receipt: {error}"))?;
71 sync_parent(parent)?;
72 Ok(source)
73}
74
75fn validate_plain_filename(value: &str, field: &str) -> Result<(), String> {
76 let path = Path::new(value);
77 if value.is_empty() || path.file_name().and_then(|name| name.to_str()) != Some(value) {
78 return Err(format!("{field} must be one plain UTF-8 filename"));
79 }
80 Ok(())
81}
82
83fn validate_digest(path: &Path, expected: &str, label: &str) -> Result<(), String> {
84 let bytes = std::fs::read(path).map_err(|error| format!("read {label}: {error}"))?;
85 let actual = crate::core::agent_identity::hex_encode(&Sha256::digest(&bytes));
86 if actual != expected {
87 return Err(format!("{label} SHA-256 mismatch"));
88 }
89 Ok(())
90}
91
92fn reject_symlink(path: &Path, label: &str) -> Result<(), String> {
93 if path
94 .symlink_metadata()
95 .is_ok_and(|metadata| metadata.file_type().is_symlink())
96 {
97 Err(format!("refusing symlink {label}: {}", path.display()))
98 } else {
99 Ok(())
100 }
101}
102
103fn sync_parent(parent: &Path) -> Result<(), String> {
104 crate::core::atomic_fs::sync_directory(parent)
105 .map_err(|error| format!("sync rollback directory: {error}"))
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct PackageMigrationResult {
110 pub output_path: PathBuf,
111 pub receipt_path: PathBuf,
112 pub already_migrated: bool,
113 pub receipt: PackageMigrationReceipt,
114}
115
116pub fn migrate_legacy_package(path: &Path) -> Result<PackageMigrationResult, String> {
117 if path
118 .symlink_metadata()
119 .is_ok_and(|metadata| metadata.file_type().is_symlink())
120 {
121 return Err(format!("refusing to migrate symlink: {}", path.display()));
122 }
123 if path.extension().and_then(|value| value.to_str())
124 != Some(crate::core::contracts::LEGACY_PACKAGE_EXTENSION)
125 {
126 return Err(format!(
127 "expected a .{} package",
128 crate::core::contracts::LEGACY_PACKAGE_EXTENSION
129 ));
130 }
131
132 let verification = verify_package_file(path)?;
133 if !verification.valid() {
134 return Err(format!(
135 "legacy package verification failed: {}",
136 verification.errors.join("; ")
137 ));
138 }
139
140 let bytes = std::fs::read(path).map_err(|error| format!("read legacy package: {error}"))?;
141 super::registry::admit_package_bytes(&bytes)?;
147 let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(&bytes));
148 let output_path = path.with_extension(crate::core::contracts::PACKAGE_EXTENSION);
149 let receipt_path = output_path.with_extension("ctxpkg.migration.json");
150 let _lock = lock_package_migration(&receipt_path)?;
151 let receipt = PackageMigrationReceipt {
152 receipt_version: PACKAGE_MIGRATION_RECEIPT_VERSION,
153 source_file: file_name(path)?,
154 output_file: file_name(&output_path)?,
155 source_sha256: digest.clone(),
156 output_sha256: digest,
157 byte_preserving: true,
158 source_retained_for_rollback: true,
159 };
160 let receipt_bytes = serde_json::to_vec_pretty(&receipt).map_err(|error| error.to_string())?;
161 validate_existing_file(&receipt_path, &receipt_bytes, "migration receipt")?;
162
163 let already_migrated = if output_path.exists() {
164 validate_existing_file(&output_path, &bytes, "destination")?;
165 true
166 } else {
167 create_without_overwrite(&output_path, &bytes)?;
168 false
169 };
170
171 let output_verification = verify_package_file(&output_path)?;
172 if !output_verification.valid() {
173 if !already_migrated {
174 let _ = std::fs::remove_file(&output_path);
175 }
176 return Err("migrated package failed post-write verification".to_string());
177 }
178 if !receipt_path.exists()
179 && let Err(error) = create_without_overwrite(&receipt_path, &receipt_bytes)
180 {
181 if !already_migrated {
182 let _ = std::fs::remove_file(&output_path);
183 }
184 return Err(error);
185 }
186 Ok(PackageMigrationResult {
187 output_path,
188 receipt_path,
189 already_migrated,
190 receipt,
191 })
192}
193
194fn validate_existing_file(path: &Path, expected: &[u8], label: &str) -> Result<(), String> {
195 if !path.exists() {
196 return Ok(());
197 }
198 let existing =
199 std::fs::read(path).map_err(|error| format!("read existing {label}: {error}"))?;
200 if existing == expected {
201 Ok(())
202 } else {
203 Err(format!(
204 "refusing to overwrite non-identical {label}: {}",
205 path.display()
206 ))
207 }
208}
209
210fn file_name(path: &Path) -> Result<String, String> {
211 path.file_name()
212 .and_then(|value| value.to_str())
213 .map(str::to_string)
214 .ok_or_else(|| format!("package filename is not UTF-8: {}", path.display()))
215}
216
217fn create_without_overwrite(path: &Path, bytes: &[u8]) -> Result<(), String> {
218 let name = path
219 .file_name()
220 .and_then(|value| value.to_str())
221 .ok_or_else(|| format!("invalid output filename: {}", path.display()))?;
222 let staged = path.with_file_name(format!(".{name}.staged"));
223 reject_symlink(&staged, "staged migration file")?;
224 if staged.exists() {
225 std::fs::remove_file(&staged)
226 .map_err(|error| format!("remove stale staged migration file: {error}"))?;
227 }
228 let mut file = std::fs::OpenOptions::new()
229 .write(true)
230 .create_new(true)
231 .open(&staged)
232 .map_err(|error| format!("create {}: {error}", staged.display()))?;
233 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
234 let _ = std::fs::remove_file(&staged);
235 return Err(format!("write {}: {error}", staged.display()));
236 }
237 std::fs::hard_link(&staged, path)
238 .map_err(|error| format!("publish {} without overwrite: {error}", path.display()))?;
239 std::fs::remove_file(&staged)
240 .map_err(|error| format!("remove staged migration file: {error}"))?;
241 if let Some(parent) = path.parent() {
242 sync_parent(parent)?;
243 }
244 Ok(())
245}
246
247fn lock_package_migration(
248 receipt_path: &Path,
249) -> Result<crate::core::migration_lock::MigrationLock, String> {
250 let lock_path = receipt_path.with_extension("json.lock");
251 reject_symlink(&lock_path, "migration lock")?;
252 crate::core::migration_lock::acquire(&lock_path)
253}
254
255#[cfg(test)]
256mod tests {
257 use chrono::{TimeZone, Utc};
258
259 use super::*;
260 use crate::core::context_package::content::PackageContent;
261 use crate::core::context_package::manifest::{
262 CompatibilitySpec, PackageIntegrity, PackageLayer, PackageManifest, PackageProvenance,
263 PackageStats,
264 };
265
266 #[derive(Serialize)]
267 struct Bundle {
268 manifest: PackageManifest,
269 content: PackageContent,
270 }
271
272 fn legacy_package(path: &Path, signed: bool) -> Vec<u8> {
273 let content = PackageContent::default();
274 let content_json = serde_json::to_string(&content).unwrap();
275 let content_hash =
276 crate::core::agent_identity::hex_encode(&Sha256::digest(content_json.as_bytes()));
277 let composite = format!("legacy-fixture:1.0.0:{content_hash}");
278 let mut manifest = PackageManifest {
279 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
280 conformance_level: None,
281 kind: Default::default(),
282 name: "legacy-fixture".to_string(),
283 version: "1.0.0".to_string(),
284 description: "v3 package migration fixture".to_string(),
285 author: None,
286 scope: None,
287 created_at: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(),
288 updated_at: None,
289 layers: vec![PackageLayer::Knowledge],
290 dependencies: vec![],
291 tags: vec![],
292 visibility: None,
293 integrity: PackageIntegrity {
294 sha256: crate::core::agent_identity::hex_encode(&Sha256::digest(
295 composite.as_bytes(),
296 )),
297 content_hash,
298 byte_size: content_json.len() as u64,
299 },
300 provenance: PackageProvenance {
301 tool: "lean-ctx".to_string(),
302 tool_version: "3.10.1".to_string(),
303 project_hash: None,
304 source_session_id: None,
305 },
306 compatibility: CompatibilitySpec::default(),
307 stats: PackageStats::default(),
308 signature: None,
309 graph_summary: None,
310 marketplace: None,
311 redistributable: None,
312 };
313 if signed {
314 let key = ed25519_dalek::SigningKey::from_bytes(&[11; 32]);
315 crate::core::context_package::signing::sign_package(&mut manifest, &content, &key);
316 }
317 let bytes = serde_json::to_vec_pretty(&Bundle { manifest, content }).unwrap();
318 std::fs::write(path, &bytes).unwrap();
319 bytes
320 }
321
322 #[test]
323 fn signed_legacy_package_migrates_byte_for_byte_and_is_idempotent() {
324 let dir = tempfile::tempdir().unwrap();
325 let source = dir.path().join("fixture.lctxpkg");
326 let original = legacy_package(&source, true);
327
328 let first = migrate_legacy_package(&source).unwrap();
329 assert!(!first.already_migrated);
330 assert_eq!(std::fs::read(&first.output_path).unwrap(), original);
331 assert_eq!(std::fs::read(&source).unwrap(), original);
332 assert_eq!(first.receipt.source_sha256, first.receipt.output_sha256);
333
334 let second = migrate_legacy_package(&source).unwrap();
335 assert!(second.already_migrated);
336 assert_eq!(first.receipt, second.receipt);
337 }
338
339 #[test]
340 fn rollback_removes_only_verified_generated_files() {
341 let dir = tempfile::tempdir().unwrap();
342 let source = dir.path().join("fixture.lctxpkg");
343 let original = legacy_package(&source, true);
344 let migrated = migrate_legacy_package(&source).unwrap();
345
346 let restored = rollback_legacy_package(&migrated.receipt_path).unwrap();
347 assert_eq!(restored, source);
348 assert_eq!(std::fs::read(&source).unwrap(), original);
349 assert!(!migrated.output_path.exists());
350 assert!(!migrated.receipt_path.exists());
351 }
352
353 #[test]
354 fn rollback_refuses_changed_output_without_mutation() {
355 let dir = tempfile::tempdir().unwrap();
356 let source = dir.path().join("fixture.lctxpkg");
357 legacy_package(&source, false);
358 let migrated = migrate_legacy_package(&source).unwrap();
359 std::fs::write(&migrated.output_path, b"changed").unwrap();
360
361 assert!(rollback_legacy_package(&migrated.receipt_path).is_err());
362 assert_eq!(std::fs::read(&migrated.output_path).unwrap(), b"changed");
363 assert!(migrated.receipt_path.exists());
364 }
365
366 #[test]
367 fn rollback_recovers_after_output_was_already_removed() {
368 let dir = tempfile::tempdir().unwrap();
369 let source = dir.path().join("fixture.lctxpkg");
370 legacy_package(&source, false);
371 let migrated = migrate_legacy_package(&source).unwrap();
372 std::fs::remove_file(&migrated.output_path).unwrap();
373
374 assert_eq!(
375 rollback_legacy_package(&migrated.receipt_path).unwrap(),
376 source
377 );
378 assert!(!migrated.receipt_path.exists());
379 }
380
381 #[test]
382 fn rollback_rejects_receipt_moved_away_from_bound_output() {
383 let dir = tempfile::tempdir().unwrap();
384 let source = dir.path().join("fixture.lctxpkg");
385 legacy_package(&source, false);
386 let migrated = migrate_legacy_package(&source).unwrap();
387 let moved = dir.path().join("unbound.migration.json");
388 std::fs::rename(&migrated.receipt_path, &moved).unwrap();
389
390 assert!(rollback_legacy_package(&moved).is_err());
391 assert!(migrated.output_path.exists());
392 }
393
394 #[test]
395 fn rollback_rejects_receipt_that_aliases_source_and_output() {
396 let dir = tempfile::tempdir().unwrap();
397 let source = dir.path().join("fixture.lctxpkg");
398 legacy_package(&source, false);
399 let migrated = migrate_legacy_package(&source).unwrap();
400 let mut receipt = migrated.receipt.clone();
401 receipt.output_file = receipt.source_file.clone();
402 std::fs::write(
403 &migrated.receipt_path,
404 serde_json::to_vec_pretty(&receipt).unwrap(),
405 )
406 .unwrap();
407
408 assert!(rollback_legacy_package(&migrated.receipt_path).is_err());
409 assert!(source.exists());
410 assert!(migrated.output_path.exists());
411 assert!(migrated.receipt_path.exists());
412 }
413
414 #[test]
415 fn tampered_signed_package_is_rejected_without_output() {
416 let dir = tempfile::tempdir().unwrap();
417 let source = dir.path().join("fixture.lctxpkg");
418 let mut bytes = legacy_package(&source, true);
419 let index = bytes.iter().position(|byte| *byte == b'v').unwrap();
420 bytes[index] = b'x';
421 std::fs::write(&source, bytes).unwrap();
422
423 assert!(migrate_legacy_package(&source).is_err());
424 assert!(!source.with_extension("ctxpkg").exists());
425 }
426
427 #[test]
428 fn conflicting_destination_is_never_overwritten() {
429 let dir = tempfile::tempdir().unwrap();
430 let source = dir.path().join("fixture.lctxpkg");
431 legacy_package(&source, false);
432 let output = source.with_extension("ctxpkg");
433 std::fs::write(&output, b"owned").unwrap();
434
435 assert!(migrate_legacy_package(&source).is_err());
436 assert_eq!(std::fs::read(output).unwrap(), b"owned");
437 }
438
439 #[test]
440 fn conflicting_receipt_does_not_create_destination() {
441 let dir = tempfile::tempdir().unwrap();
442 let source = dir.path().join("fixture.lctxpkg");
443 legacy_package(&source, false);
444 let output = source.with_extension("ctxpkg");
445 let receipt = output.with_extension("ctxpkg.migration.json");
446 std::fs::write(&receipt, b"owned").unwrap();
447
448 assert!(migrate_legacy_package(&source).is_err());
449 assert!(!output.exists());
450 assert_eq!(std::fs::read(receipt).unwrap(), b"owned");
451 }
452
453 #[cfg(unix)]
454 #[test]
455 fn symlink_source_is_rejected() {
456 use std::os::unix::fs::symlink;
457
458 let dir = tempfile::tempdir().unwrap();
459 let target = dir.path().join("target.lctxpkg");
460 legacy_package(&target, false);
461 let source = dir.path().join("fixture.lctxpkg");
462 symlink(&target, &source).unwrap();
463
464 assert!(migrate_legacy_package(&source).is_err());
465 assert!(!source.with_extension("ctxpkg").exists());
466 }
467}