Skip to main content

weavatrix_scan/
portable_report.rs

1use crate::hash::FingerprintHasher;
2use crate::report::{
3    IgnoreSourceEvidence, IgnoreSourceKind, ScanReport, ScanTermination, ScanWarning, ScannedFile,
4    SkipKind, SkippedEntry,
5};
6use std::path::{Component, Path};
7
8/// A selected file without host-local absolute paths or file identities.
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct PortableScannedFile {
12    pub relative: String,
13    pub bytes: u64,
14    pub content_hash: Option<String>,
15    pub binary_checked: bool,
16}
17
18/// Typed skip evidence with free-form details replaced by a stable hash.
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct PortableSkippedEntry {
22    pub relative: String,
23    pub kind: SkipKind,
24    pub detail_hash: Option<String>,
25}
26
27/// Warning evidence that cannot expose paths embedded in an OS error message.
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PortableScanWarning {
31    pub relative: Option<String>,
32    pub message_hash: String,
33}
34
35/// Ignore-source evidence with external host paths removed.
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PortableIgnoreSourceEvidence {
39    pub kind: IgnoreSourceKind,
40    pub repository_relative: Option<String>,
41    pub content_hash: String,
42}
43
44/// A deterministic report suitable for crossing a repository or process boundary.
45///
46/// Absolute roots, absolute file paths, file identities, timestamps, cache
47/// statistics, and free-form diagnostic text are intentionally omitted.
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct PortableScanReport {
51    pub files: Vec<PortableScannedFile>,
52    pub skipped: Vec<PortableSkippedEntry>,
53    pub warnings: Vec<PortableScanWarning>,
54    pub ignore_sources: Vec<PortableIgnoreSourceEvidence>,
55    pub revision: String,
56    pub complete: bool,
57    pub termination: Option<ScanTermination>,
58    /// Whether selection itself was independent of host-level Git configuration.
59    pub selection_portable: bool,
60}
61
62impl ScanReport {
63    /// Returns path-safe evidence for IPC, logs, caches, and external tools.
64    #[must_use]
65    pub fn to_portable(&self) -> PortableScanReport {
66        PortableScanReport::from(self)
67    }
68}
69
70impl From<&ScanReport> for PortableScanReport {
71    fn from(report: &ScanReport) -> Self {
72        let mut ignore_sources = report
73            .ignore_sources
74            .iter()
75            .map(PortableIgnoreSourceEvidence::from)
76            .collect::<Vec<_>>();
77        ignore_sources.sort_unstable_by(|left, right| {
78            left.kind
79                .cmp(&right.kind)
80                .then_with(|| left.repository_relative.cmp(&right.repository_relative))
81                .then_with(|| left.content_hash.cmp(&right.content_hash))
82        });
83        ignore_sources.dedup();
84        let mut portable = Self {
85            files: report.files.iter().map(PortableScannedFile::from).collect(),
86            skipped: report
87                .skipped
88                .iter()
89                .map(PortableSkippedEntry::from)
90                .collect(),
91            warnings: report
92                .warnings
93                .iter()
94                .map(PortableScanWarning::from)
95                .collect(),
96            ignore_sources,
97            revision: String::new(),
98            complete: report.complete,
99            termination: report.termination,
100            selection_portable: report.portable,
101        };
102        portable.revision = portable_revision(&portable);
103        portable
104    }
105}
106
107impl From<&ScannedFile> for PortableScannedFile {
108    fn from(file: &ScannedFile) -> Self {
109        Self {
110            relative: file.relative.clone(),
111            bytes: file.bytes,
112            content_hash: file.content_hash.clone(),
113            binary_checked: file.binary_checked,
114        }
115    }
116}
117
118impl From<&SkippedEntry> for PortableSkippedEntry {
119    fn from(skipped: &SkippedEntry) -> Self {
120        Self {
121            relative: skipped.relative.clone(),
122            kind: skipped.kind,
123            detail_hash: skipped.detail.as_deref().map(hash_text),
124        }
125    }
126}
127
128impl From<&ScanWarning> for PortableScanWarning {
129    fn from(warning: &ScanWarning) -> Self {
130        Self {
131            relative: warning.relative.clone(),
132            message_hash: hash_text(&warning.message),
133        }
134    }
135}
136
137impl From<&IgnoreSourceEvidence> for PortableIgnoreSourceEvidence {
138    fn from(source: &IgnoreSourceEvidence) -> Self {
139        Self {
140            kind: source.kind,
141            repository_relative: repository_relative_location(&source.location),
142            content_hash: source.content_hash.clone(),
143        }
144    }
145}
146
147fn repository_relative_location(location: &str) -> Option<String> {
148    if location.starts_with('<') && location.ends_with('>') {
149        return Some(location.to_owned());
150    }
151    if looks_absolute(location) {
152        return None;
153    }
154    let path = Path::new(location);
155    if path
156        .components()
157        .any(|component| !matches!(component, Component::Normal(_)))
158    {
159        return None;
160    }
161    Some(location.replace('\\', "/"))
162}
163
164fn looks_absolute(location: &str) -> bool {
165    let bytes = location.as_bytes();
166    location.starts_with(['/', '\\'])
167        || bytes.get(1) == Some(&b':') && bytes.first().is_some_and(u8::is_ascii_alphabetic)
168}
169
170fn hash_text(text: &str) -> String {
171    let mut hash = FingerprintHasher::new();
172    hash.write(text.as_bytes());
173    hash.finish()
174}
175
176fn portable_revision(report: &PortableScanReport) -> String {
177    let mut revision = FingerprintHasher::new();
178    for source in &report.ignore_sources {
179        revision.write(format!("{:?}", source.kind).as_bytes());
180        revision.write(&[0]);
181        revision.write(
182            source
183                .repository_relative
184                .as_deref()
185                .unwrap_or("<external>")
186                .as_bytes(),
187        );
188        revision.write(&[0]);
189        revision.write(source.content_hash.as_bytes());
190        revision.write(&[0xfe]);
191    }
192    for file in &report.files {
193        revision.write(file.relative.as_bytes());
194        revision.write(&[0]);
195        revision.write(&file.bytes.to_le_bytes());
196        revision.write(file.content_hash.as_deref().unwrap_or("").as_bytes());
197        revision.write(&[0xff]);
198    }
199    revision.write(&[u8::from(report.complete)]);
200    revision.write(&[u8::from(report.selection_portable)]);
201    if let Some(termination) = report.termination {
202        revision.write(format!("{termination:?}").as_bytes());
203    }
204    revision.finish()
205}