Skip to main content

lean_ctx/core/context_package/
migration.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Byte-preserving migration from the pre-v3.6.14 `.lctxpkg` name to `.ctxpkg`.
4
5use 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    // The migration copies these bytes under a new name. They are copied
142    // only if the package they hold passes the CURRENT admission as it is —
143    // a package that would be refused today never becomes a fresh `.ctxpkg`.
144    // Nothing has been written at this point; the legacy file is not touched
145    // by a refusal (nor by a migration: it is retained for rollback).
146    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}