1use std::collections::HashSet;
2use std::fmt;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6use crate::error::{Error, Result};
7use crate::stage::{PreparedTransaction, Stage};
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct ProductId(String);
12
13impl ProductId {
14 pub fn new(value: impl Into<String>) -> Result<Self> {
16 Ok(Self(validate_identifier("product", value.into())?))
17 }
18
19 pub fn as_str(&self) -> &str {
21 &self.0
22 }
23}
24
25impl fmt::Display for ProductId {
26 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27 formatter.write_str(&self.0)
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33pub struct ReleaseId(String);
34
35impl ReleaseId {
36 pub fn new(value: impl Into<String>) -> Result<Self> {
38 Ok(Self(validate_identifier("release", value.into())?))
39 }
40
41 pub fn as_str(&self) -> &str {
43 &self.0
44 }
45}
46
47impl fmt::Display for ReleaseId {
48 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49 formatter.write_str(&self.0)
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct MemberId(String);
56
57impl MemberId {
58 pub fn new(value: impl Into<String>) -> Result<Self> {
60 Ok(Self(validate_identifier("member", value.into())?))
61 }
62
63 pub fn as_str(&self) -> &str {
65 &self.0
66 }
67}
68
69impl fmt::Display for MemberId {
70 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71 formatter.write_str(&self.0)
72 }
73}
74
75fn validate_identifier(kind: &str, value: String) -> Result<String> {
76 if value.is_empty() || value.chars().any(char::is_control) {
77 return Err(Error::invalid(format!(
78 "{kind} identifier is empty or contains control characters"
79 )));
80 }
81 Ok(value)
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86#[non_exhaustive]
87pub enum FileKind {
88 Regular,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum PermissionsIntent {
100 Preserve,
102 Executable,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum Ownership {
113 Absent,
115 Owned,
117 Foreign,
119 Unknown,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129#[non_exhaustive]
130pub enum IntegrityRequirement {
131 None,
133 Sha256([u8; 32]),
135}
136
137pub trait OwnershipVerifier {
148 fn verify(&self, member: &MemberId, destination: &Path) -> Ownership;
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum AbsentPolicy {
159 AllowCreate,
161 DenyCreate,
163}
164
165#[derive(Clone, Copy)]
168pub struct CommitOwnership<'a> {
169 pub verifier: &'a dyn OwnershipVerifier,
171 pub absent: AbsentPolicy,
173}
174
175impl<'a> CommitOwnership<'a> {
176 pub fn new(verifier: &'a dyn OwnershipVerifier, absent: AbsentPolicy) -> Self {
178 Self { verifier, absent }
179 }
180}
181
182impl std::fmt::Debug for CommitOwnership<'_> {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 f.debug_struct("CommitOwnership")
185 .field("absent", &self.absent)
186 .finish_non_exhaustive()
187 }
188}
189
190#[derive(Debug, Clone, Copy, Default)]
194pub struct AbsentOnlyVerifier;
195
196impl OwnershipVerifier for AbsentOnlyVerifier {
197 fn verify(&self, _member: &MemberId, destination: &Path) -> Ownership {
198 match std::fs::symlink_metadata(destination) {
199 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ownership::Absent,
200 Ok(_) => Ownership::Foreign,
201 Err(_) => Ownership::Unknown,
202 }
203 }
204}
205
206#[derive(Debug, Clone, Copy, Default)]
213pub struct ExistingAsOwnedVerifier;
214
215impl OwnershipVerifier for ExistingAsOwnedVerifier {
216 fn verify(&self, _member: &MemberId, destination: &Path) -> Ownership {
217 match std::fs::symlink_metadata(destination) {
218 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ownership::Absent,
219 Ok(m) if m.is_file() => Ownership::Owned,
220 Ok(_) => Ownership::Foreign,
221 Err(_) => Ownership::Unknown,
222 }
223 }
224}
225
226#[derive(Debug, Clone)]
232pub struct ExactDigestVerifier {
233 expected: std::collections::HashMap<MemberId, [u8; 32]>,
234}
235
236impl ExactDigestVerifier {
237 pub fn new(expected: Vec<(MemberId, [u8; 32])>) -> Self {
239 Self {
240 expected: expected.into_iter().collect(),
241 }
242 }
243}
244
245impl OwnershipVerifier for ExactDigestVerifier {
246 fn verify(&self, member: &MemberId, destination: &Path) -> Ownership {
247 let expected = match self.expected.get(member) {
248 Some(d) => d,
249 None => return Ownership::Unknown,
250 };
251 match std::fs::symlink_metadata(destination) {
252 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ownership::Absent,
253 Err(_) => Ownership::Unknown,
254 Ok(m) => {
255 if !m.is_file() {
256 return Ownership::Foreign;
257 }
258 #[cfg(unix)]
259 {
260 use std::os::unix::fs::MetadataExt;
261 if m.nlink() != 1 {
262 return Ownership::Foreign;
263 }
264 }
265 match crate::integrity::hash_file(destination) {
266 Ok(d) if &d == expected => Ownership::Owned,
267 Ok(_) => Ownership::Foreign,
268 Err(_) => Ownership::Unknown,
269 }
270 }
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct ArtifactMember {
282 id: MemberId,
283 source: PathBuf,
284 destination: PathBuf,
285 file_kind: FileKind,
286 permissions: PermissionsIntent,
287 integrity: IntegrityRequirement,
288}
289
290impl ArtifactMember {
291 pub fn new(
293 id: MemberId,
294 source: impl Into<PathBuf>,
295 destination: impl Into<PathBuf>,
296 ) -> Result<Self> {
297 let destination = normalize_relative_path(&destination.into())?;
298 Ok(Self {
299 id,
300 source: source.into(),
301 destination,
302 file_kind: FileKind::Regular,
303 permissions: PermissionsIntent::Preserve,
304 integrity: IntegrityRequirement::None,
305 })
306 }
307
308 pub fn with_permissions(mut self, permissions: PermissionsIntent) -> Self {
310 self.permissions = permissions;
311 self
312 }
313
314 pub fn with_integrity(mut self, integrity: IntegrityRequirement) -> Self {
320 self.integrity = integrity;
321 self
322 }
323
324 pub fn id(&self) -> &MemberId {
326 &self.id
327 }
328
329 pub fn source(&self) -> &Path {
331 &self.source
332 }
333
334 pub fn destination(&self) -> &Path {
336 &self.destination
337 }
338
339 pub fn file_kind(&self) -> FileKind {
341 self.file_kind
342 }
343
344 pub fn permissions(&self) -> PermissionsIntent {
346 self.permissions
347 }
348
349 pub fn integrity(&self) -> IntegrityRequirement {
351 self.integrity
352 }
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct ArtifactSet(Vec<ArtifactMember>);
358
359impl ArtifactSet {
360 pub fn new(members: Vec<ArtifactMember>) -> Result<Self> {
362 if members.is_empty() {
363 return Err(Error::invalid(
364 "artifact set must contain at least one member",
365 ));
366 }
367 let mut ids = HashSet::new();
368 for member in &members {
369 if !ids.insert(member.id.clone()) {
370 return Err(Error::invalid(format!(
371 "duplicate artifact member: {}",
372 member.id
373 )));
374 }
375 }
376 Ok(Self(members))
377 }
378
379 pub fn single(member: ArtifactMember) -> Result<Self> {
381 Self::new(vec![member])
382 }
383
384 pub fn iter(&self) -> impl ExactSizeIterator<Item = &ArtifactMember> {
386 self.0.iter()
387 }
388
389 pub fn len(&self) -> usize {
391 self.0.len()
392 }
393
394 pub fn is_empty(&self) -> bool {
396 self.0.is_empty()
397 }
398
399 pub(crate) fn as_slice(&self) -> &[ArtifactMember] {
400 &self.0
401 }
402}
403
404#[derive(Debug, Clone, PartialEq, Eq)]
406pub struct InstallPlan {
407 product: ProductId,
408 release: ReleaseId,
409 installation_root: PathBuf,
410 artifacts: ArtifactSet,
411}
412
413impl InstallPlan {
414 pub fn new(
416 product: ProductId,
417 release: ReleaseId,
418 installation_root: impl Into<PathBuf>,
419 artifacts: ArtifactSet,
420 ) -> Result<Self> {
421 let installation_root = installation_root.into();
422 validate_installation_root(&installation_root)?;
423 let canonical_root = fs::canonicalize(&installation_root)
424 .map_err(|source| Error::io("canonicalizing installation root", source))?;
425 let mut destinations = HashSet::new();
426 for member in artifacts.as_slice() {
427 if !destinations.insert(member.destination.clone()) {
428 return Err(Error::invalid(format!(
429 "duplicate destination: {}",
430 member.destination.display()
431 )));
432 }
433 validate_source(member, &canonical_root, &installation_root)?;
434 }
435 Ok(Self {
436 product,
437 release,
438 installation_root,
439 artifacts,
440 })
441 }
442
443 pub fn product(&self) -> &ProductId {
445 &self.product
446 }
447
448 pub fn release(&self) -> &ReleaseId {
450 &self.release
451 }
452
453 pub fn installation_root(&self) -> &Path {
455 &self.installation_root
456 }
457
458 pub fn artifacts(&self) -> &ArtifactSet {
460 &self.artifacts
461 }
462
463 pub fn destination(&self, member: &MemberId) -> Result<PathBuf> {
465 let artifact = self
466 .artifacts
467 .iter()
468 .find(|candidate| candidate.id == *member)
469 .ok_or_else(|| Error::UnknownMember(member.to_string()))?;
470 Ok(self.installation_root.join(&artifact.destination))
471 }
472
473 pub fn prepare(self) -> Result<PreparedTransaction> {
475 Stage::prepare(self)
476 }
477
478 #[cfg(test)]
479 pub(crate) fn prepare_with_failure(
480 self,
481 point: crate::test_support::FailurePoint,
482 ) -> Result<PreparedTransaction> {
483 Stage::prepare_with_failure(self, point)
484 }
485}
486
487fn validate_installation_root(root: &Path) -> Result<()> {
488 if !root.is_absolute() {
489 return Err(Error::invalid("installation root must be absolute"));
490 }
491 let metadata = fs::symlink_metadata(root)
492 .map_err(|source| Error::io("reading installation root", source))?;
493 if !metadata.is_dir() {
494 return Err(Error::invalid("installation root must be a directory"));
495 }
496 Ok(())
497}
498
499fn validate_source(
500 member: &ArtifactMember,
501 canonical_root: &Path,
502 installation_root: &Path,
503) -> Result<()> {
504 if !member.source.is_absolute() {
505 return Err(Error::invalid(format!(
506 "source for {} must be absolute",
507 member.id
508 )));
509 }
510 let metadata = fs::symlink_metadata(&member.source)
511 .map_err(|source| Error::io("reading artifact source", source))?;
512 if member.file_kind == FileKind::Regular && !metadata.is_file() {
513 return Err(Error::invalid(format!(
514 "source for {} is not a regular file",
515 member.id
516 )));
517 }
518 let canonical_source = fs::canonicalize(&member.source)
519 .map_err(|source| Error::io("canonicalizing artifact source", source))?;
520 if canonical_source.starts_with(canonical_root) {
521 return Err(Error::invalid(format!(
522 "source for {} is inside the installation root",
523 member.id
524 )));
525 }
526 let destination = installation_root.join(&member.destination);
527 if canonical_source == destination {
528 return Err(Error::invalid(format!(
529 "source for {} aliases its destination",
530 member.id
531 )));
532 }
533 Ok(())
534}
535
536pub(crate) fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
537 if path.as_os_str().is_empty() || path.is_absolute() {
538 return Err(Error::invalid(
539 "destination must be a non-empty relative path",
540 ));
541 }
542 let mut normalized = PathBuf::new();
543 for component in path.components() {
544 match component {
545 Component::Normal(value) => {
546 if value.to_string_lossy().chars().any(char::is_control) {
547 return Err(Error::invalid("destination contains control characters"));
548 }
549 normalized.push(value);
550 }
551 Component::CurDir => {}
552 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
553 return Err(Error::invalid("destination escapes the installation root"));
554 }
555 }
556 }
557 if normalized.as_os_str().is_empty() {
558 return Err(Error::invalid("destination must name a file"));
559 }
560 Ok(normalized)
561}