cargo-rail 0.27.0

Cargo-native Rust workspace engine for affected CI, Rust source-surface analysis, compiler reuse, dependency coherence, exact-SHA releases, and split/sync
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
//! Repository-boundary capabilities for split and sync mutations.

use crate::error::{GitError, RailError, RailResult};
use crate::git::SystemGit;
use crate::utils;
use glob::Pattern;
use std::fs;
use std::path::{Path, PathBuf};

/// Validated filesystem roots owned by one split target.
///
/// Construction proves that source crates are real directories inside the source
/// workspace and that the target cannot overlap the source Git worktree.
#[derive(Debug, Clone)]
pub struct SplitPathCapabilities {
    source_workspace: PathBuf,
    source_worktree: PathBuf,
    target_root: PathBuf,
    temporary_root: PathBuf,
    crate_roots: Vec<PathBuf>,
    asset_paths: Vec<PathBuf>,
    asset_includes: Vec<Pattern>,
    asset_excludes: Vec<Pattern>,
}

impl SplitPathCapabilities {
    /// Validate and resolve all roots before any split mutation begins.
    pub fn new(
        workspace_root: &Path,
        worktree_root: &Path,
        crate_paths: &[PathBuf],
        target_root: &Path,
    ) -> RailResult<Self> {
        let source_workspace = canonical_existing(workspace_root, "source workspace")?;
        let source_worktree = canonical_existing(worktree_root, "source worktree")?;
        if !source_workspace.starts_with(&source_worktree) {
            return Err(boundary_error(
                "source workspace",
                &source_workspace,
                "is outside its Git worktree",
            ));
        }

        let mut crate_roots = Vec::with_capacity(crate_paths.len());
        for crate_path in crate_paths {
            let joined = if crate_path.is_absolute() {
                crate_path.clone()
            } else {
                source_workspace.join(crate_path)
            };
            let resolved = canonical_existing(&joined, "split crate path")?;
            if !resolved.starts_with(&source_workspace) {
                return Err(boundary_error(
                    "split crate path",
                    &resolved,
                    "escapes the source workspace (including through a symlink)",
                ));
            }
            if !resolved.is_dir() {
                return Err(boundary_error("split crate path", &resolved, "is not a directory"));
            }
            crate_roots.push(resolved);
        }

        let target_root = resolve_allow_missing(target_root, "split target")?;
        reject_overlap("split target", &target_root, "source worktree", &source_worktree)?;

        let temporary_root = resolve_allow_missing(
            &std::env::temp_dir().join("cargo-rail").join("split"),
            "split temporary root",
        )?;
        reject_overlap(
            "split temporary root",
            &temporary_root,
            "source worktree",
            &source_worktree,
        )?;
        reject_overlap("split temporary root", &temporary_root, "split target", &target_root)?;

        Ok(Self {
            source_workspace,
            source_worktree,
            target_root,
            temporary_root,
            crate_roots,
            asset_paths: Vec::new(),
            asset_includes: Vec::new(),
            asset_excludes: Vec::new(),
        })
    }

    /// Add the exact non-Cargo asset paths owned by this split.
    pub fn with_asset_paths(mut self, asset_paths: &[PathBuf]) -> RailResult<Self> {
        let mut resolved = Vec::with_capacity(asset_paths.len());
        for path in asset_paths {
            let path = self.logical_source_mutation_path(path)?;
            if self.crate_roots.iter().any(|root| path.starts_with(root)) {
                return Err(boundary_error(
                    "split asset path",
                    &path,
                    "overlaps a Cargo-owned crate root",
                ));
            }
            resolved.push(path);
        }
        resolved.sort();
        resolved.dedup();
        self.asset_paths = resolved;
        Ok(self)
    }

    /// Bind the stable configured asset selectors independently of the files
    /// that happen to match them in the current checkout. Historical add,
    /// delete, and rename commits must be authorized by policy, not by today's
    /// resolved file list.
    pub(crate) fn with_asset_policy(mut self, include: &[String], exclude: &[String]) -> RailResult<Self> {
        let compile = |values: &[String]| {
            values
                .iter()
                .map(|value| {
                    Pattern::new(value)
                        .map_err(|error| RailError::message(format!("invalid split asset glob '{value}': {error}")))
                })
                .collect::<RailResult<Vec<_>>>()
        };
        self.asset_includes = compile(include)?;
        self.asset_excludes = compile(exclude)?;
        Ok(self)
    }

    /// Whether one repository-relative source path is owned by the stable
    /// Cargo-root or explicit-asset policy. This intentionally does not require
    /// the path to exist in the current worktree.
    pub(crate) fn owns_source_path(&self, path: &Path) -> RailResult<bool> {
        let relative = self.logical_source_relative(path)?;
        let absolute = self.source_workspace.join(&relative);
        if self.crate_roots.iter().any(|root| absolute.starts_with(root)) {
            return Ok(true);
        }
        Ok(self.asset_policy_matches(&relative))
    }

    /// Whether a repository-relative path is owned specifically by the stable
    /// non-Cargo asset selectors.
    pub(crate) fn owns_asset_path(&self, path: &Path) -> RailResult<bool> {
        let relative = self.logical_source_relative(path)?;
        Ok(self.asset_policy_matches(&relative))
    }

    fn asset_policy_matches(&self, relative: &Path) -> bool {
        let path = utils::path_to_git_format(relative);
        self.asset_includes.iter().any(|pattern| pattern.matches(&path))
            && !self.asset_excludes.iter().any(|pattern| pattern.matches(&path))
    }

    fn logical_source_relative(&self, path: &Path) -> RailResult<PathBuf> {
        let relative = if path.is_absolute() {
            path.strip_prefix(&self.source_workspace)
                .map_err(|_| boundary_error("split source path", path, "is outside the authorized source workspace"))?
        } else {
            path
        };
        Ok(crate::source::RepositoryPath::new(relative)?.as_path().to_path_buf())
    }

    /// Canonical source workspace root.
    pub fn source_workspace(&self) -> &Path {
        &self.source_workspace
    }

    /// Canonical split target root, including a normalized non-existent suffix.
    pub fn target_root(&self) -> &Path {
        &self.target_root
    }

    /// Validate a target path immediately before mutation.
    ///
    /// Existing symlinks are resolved on every call so a destination cannot redirect
    /// a write outside the target after initial configuration validation.
    pub fn authorize_target(&self, path: &Path) -> RailResult<PathBuf> {
        let candidate = if path.is_absolute() {
            path.to_path_buf()
        } else {
            self.target_root.join(path)
        };
        let resolved = resolve_allow_missing(&candidate, "split mutation path")?;
        if !resolved.starts_with(&self.target_root) {
            return Err(boundary_error(
                "split mutation path",
                &resolved,
                "escapes the authorized target root (including through a symlink)",
            ));
        }
        Ok(resolved)
    }

    /// Validate a source path immediately before reading or copying it.
    pub fn authorize_source(&self, path: &Path) -> RailResult<PathBuf> {
        let candidate = if path.is_absolute() {
            path.to_path_buf()
        } else {
            self.source_workspace.join(path)
        };
        let resolved = canonical_existing(&candidate, "split source path")?;
        if !resolved.starts_with(&self.source_workspace) {
            return Err(boundary_error(
                "split source path",
                &resolved,
                "escapes the authorized source workspace (including through a symlink)",
            ));
        }
        Ok(resolved)
    }

    /// Validate a possibly new source-workspace path immediately before mutation.
    pub fn authorize_source_mutation(&self, path: &Path) -> RailResult<PathBuf> {
        let resolved = self.logical_source_mutation_path(path)?;
        if self.crate_roots.iter().any(|root| resolved.starts_with(root))
            || self.asset_paths.contains(&resolved)
            || self.owns_asset_path(path)?
        {
            return Ok(resolved);
        }
        Err(boundary_error(
            "sync source mutation path",
            &resolved,
            "is outside the Cargo roots and explicit non-Cargo assets owned by this split",
        ))
    }

    fn logical_source_mutation_path(&self, path: &Path) -> RailResult<PathBuf> {
        let candidate = if path.is_absolute() {
            path.to_path_buf()
        } else {
            self.source_workspace.join(path)
        };
        let file_name = candidate
            .file_name()
            .ok_or_else(|| boundary_error("sync source mutation path", &candidate, "does not identify a file"))?;
        let parent = candidate
            .parent()
            .ok_or_else(|| boundary_error("sync source mutation path", &candidate, "has no parent directory"))?;
        let mut resolved = resolve_allow_missing(parent, "sync source mutation parent")?;
        resolved.push(file_name);
        if !resolved.starts_with(&self.source_workspace) {
            return Err(boundary_error(
                "sync source mutation path",
                &resolved,
                "escapes the authorized source workspace (including through a parent symlink)",
            ));
        }
        Ok(resolved)
    }

    /// Validate a temporary path before conflict-resolution writes.
    pub fn authorize_temporary(&self, path: &Path) -> RailResult<PathBuf> {
        let candidate = if path.is_absolute() {
            path.to_path_buf()
        } else {
            self.temporary_root.join(path)
        };
        let resolved = resolve_allow_missing(&candidate, "split temporary path")?;
        if !resolved.starts_with(&self.temporary_root) {
            return Err(boundary_error(
                "split temporary path",
                &resolved,
                "escapes the authorized temporary root (including through a symlink)",
            ));
        }
        Ok(resolved)
    }

    /// Revalidate repository identity before a Git operation mutates the target.
    pub fn validate_target_repository(&self) -> RailResult<()> {
        let target = resolve_allow_missing(&self.target_root, "split target")?;
        reject_overlap("split target", &target, "source worktree", &self.source_worktree)?;
        let repository_probe = nearest_existing_ancestor(&target)?;
        match SystemGit::open(repository_probe) {
            Ok(target_git) => {
                let target_worktree = canonical_existing(&target_git.worktree_root, "target worktree")?;
                if target_worktree != self.target_root {
                    return Err(boundary_error(
                        "split target",
                        &target,
                        &format!(
                            "is nested inside unrelated Git worktree '{}'",
                            target_worktree.display()
                        ),
                    ));
                }
            }
            Err(RailError::Git(GitError::RepoNotFound { .. })) => {}
            Err(error) => return Err(error),
        }
        Ok(())
    }

    /// Prove runtime parameters still match the crate roots authorized at construction.
    pub fn validate_crate_paths(&self, crate_paths: &[PathBuf]) -> RailResult<()> {
        let mut current = Vec::with_capacity(crate_paths.len());
        for crate_path in crate_paths {
            current.push(self.authorize_source(crate_path)?);
        }
        if current != self.crate_roots {
            return Err(RailError::with_help(
                "runtime split crate paths do not match the validated path capability",
                "rebuild split parameters from the current repository configuration",
            ));
        }
        Ok(())
    }

    /// Canonical temporary root reserved for this mutation class.
    pub fn temporary_root(&self) -> &Path {
        &self.temporary_root
    }
}

fn canonical_existing(path: &Path, label: &str) -> RailResult<PathBuf> {
    utils::canonicalize_existing(path).map_err(|error| {
        RailError::with_help(
            format!("invalid {} '{}': {}", label, path.display(), error),
            "use an existing path that stays within the configured repository boundary",
        )
    })
}

fn resolve_allow_missing(path: &Path, label: &str) -> RailResult<PathBuf> {
    utils::canonicalize_allow_missing(path).map_err(|error| {
        RailError::with_help(
            format!("invalid {} '{}': {}", label, path.display(), error),
            "use a path that stays within the configured repository boundary",
        )
    })
}

fn nearest_existing_ancestor(path: &Path) -> RailResult<&Path> {
    let mut current = path;
    loop {
        match fs::symlink_metadata(current) {
            Ok(_) => return Ok(current),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                current = current
                    .parent()
                    .ok_or_else(|| boundary_error("split target", path, "has no existing ancestor"))?;
            }
            Err(error) => {
                return Err(boundary_error(
                    "split target",
                    path,
                    &format!("cannot be inspected: {}", error),
                ));
            }
        }
    }
}

fn reject_overlap(left_label: &str, left: &Path, right_label: &str, right: &Path) -> RailResult<()> {
    if left.starts_with(right) || right.starts_with(left) {
        return Err(RailError::with_help(
            format!(
                "{} '{}' overlaps {} '{}'",
                left_label,
                left.display(),
                right_label,
                right.display()
            ),
            "choose a split target outside the source repository and its ancestors",
        ));
    }
    Ok(())
}

fn boundary_error(label: &str, path: &Path, reason: &str) -> RailError {
    RailError::with_help(
        format!("{} '{}' {}", label, path.display(), reason),
        "fix the split paths before retrying; cargo-rail did not mutate either repository",
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn roots() -> (TempDir, PathBuf, PathBuf) {
        let temp = TempDir::new().unwrap();
        let source = temp.path().join("source");
        let crate_root = source.join("crates/demo");
        fs::create_dir_all(&crate_root).unwrap();
        (temp, source, crate_root)
    }

    #[test]
    fn rejects_target_identity_and_ancestor_collisions() {
        let (_temp, source, _crate_root) = roots();
        let crate_paths = vec![PathBuf::from("crates/demo")];
        SplitPathCapabilities::new(&source, &source, &crate_paths, &source).unwrap_err();
        SplitPathCapabilities::new(&source, &source, &crate_paths, source.parent().unwrap()).unwrap_err();
    }

    #[test]
    fn rejects_crate_path_outside_workspace() {
        let (temp, source, _crate_root) = roots();
        let outside = temp.path().join("outside");
        fs::create_dir(&outside).unwrap();
        let paths = vec![PathBuf::from("../outside")];
        SplitPathCapabilities::new(&source, &source, &paths, &temp.path().join("target")).unwrap_err();
    }

    #[test]
    fn rejects_target_nested_in_another_repository() {
        let (temp, source, _crate_root) = roots();
        let other_repo = temp.path().join("other-repo");
        fs::create_dir(&other_repo).unwrap();
        crate::git::init_repo(&other_repo, "main").unwrap();
        let target = other_repo.join("nested-target");
        let capabilities =
            SplitPathCapabilities::new(&source, &source, &[PathBuf::from("crates/demo")], &target).unwrap();

        assert!(capabilities.validate_target_repository().is_err());
    }

    #[test]
    fn source_mutation_is_limited_to_crate_roots_and_exact_assets() {
        let (temp, source, _crate_root) = roots();
        let sibling = source.join("crates/sibling");
        let asset = source.join("NOTICE");
        fs::create_dir_all(&sibling).unwrap();
        fs::write(&asset, "notice").unwrap();
        let target = temp.path().join("target");
        let capabilities = SplitPathCapabilities::new(&source, &source, &[PathBuf::from("crates/demo")], &target)
            .unwrap()
            .with_asset_paths(&[PathBuf::from("NOTICE")])
            .unwrap();

        capabilities
            .authorize_source_mutation(Path::new("crates/demo/src/lib.rs"))
            .unwrap();
        capabilities.authorize_source_mutation(Path::new("NOTICE")).unwrap();
        capabilities
            .authorize_source_mutation(Path::new("crates/sibling/src/lib.rs"))
            .unwrap_err();
    }

    #[cfg(unix)]
    #[test]
    fn rejects_source_and_target_symlink_escapes() {
        use std::os::unix::fs::symlink;

        let (temp, source, _crate_root) = roots();
        let outside = temp.path().join("outside");
        fs::create_dir(&outside).unwrap();
        symlink(&outside, source.join("crates/escape")).unwrap();
        let escaped_crate = vec![PathBuf::from("crates/escape")];
        SplitPathCapabilities::new(&source, &source, &escaped_crate, &temp.path().join("target")).unwrap_err();

        let target_link = temp.path().join("target-link");
        symlink(&source, &target_link).unwrap();
        let valid_crate = vec![PathBuf::from("crates/demo")];
        SplitPathCapabilities::new(&source, &source, &valid_crate, &target_link).unwrap_err();
    }

    #[cfg(unix)]
    #[test]
    fn revalidates_destination_symlinks_before_write() {
        use std::os::unix::fs::symlink;

        let (temp, source, _crate_root) = roots();
        let target = temp.path().join("target");
        let outside = temp.path().join("outside");
        fs::create_dir(&target).unwrap();
        fs::create_dir(&outside).unwrap();
        let capabilities =
            SplitPathCapabilities::new(&source, &source, &[PathBuf::from("crates/demo")], &target).unwrap();
        symlink(&outside, target.join("redirect")).unwrap();
        capabilities
            .authorize_target(&target.join("redirect/file"))
            .unwrap_err();

        symlink(outside.join("missing"), target.join("dangling")).unwrap();
        capabilities.authorize_target(&target.join("dangling")).unwrap_err();
    }
}