weavatrix_scan/
portable_report.rs1use crate::hash::FingerprintHasher;
2use crate::report::{
3 IgnoreSourceEvidence, IgnoreSourceKind, ScanReport, ScanTermination, ScanWarning, ScannedFile,
4 SkipKind, SkippedEntry,
5};
6use std::path::{Component, Path};
7
8#[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#[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#[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#[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#[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 pub selection_portable: bool,
60}
61
62impl ScanReport {
63 #[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}