1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::fs::{self, File};
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, Default)]
418pub struct BoundSources {
419 handles: HashMap<MemberId, File>,
420}
421
422impl BoundSources {
423 pub fn new() -> Self {
425 Self {
426 handles: HashMap::new(),
427 }
428 }
429
430 pub fn insert(&mut self, member: MemberId, handle: File) -> Option<File> {
433 self.handles.insert(member, handle)
434 }
435
436 pub(crate) fn is_empty(&self) -> bool {
437 self.handles.is_empty()
438 }
439
440 pub(crate) fn take(&mut self, member: &MemberId) -> Option<File> {
441 self.handles.remove(member)
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq)]
447pub struct InstallPlan {
448 product: ProductId,
449 release: ReleaseId,
450 installation_root: PathBuf,
451 artifacts: ArtifactSet,
452}
453
454impl InstallPlan {
455 pub fn new(
457 product: ProductId,
458 release: ReleaseId,
459 installation_root: impl Into<PathBuf>,
460 artifacts: ArtifactSet,
461 ) -> Result<Self> {
462 let installation_root = installation_root.into();
463 validate_installation_root(&installation_root)?;
464 let canonical_root = fs::canonicalize(&installation_root)
465 .map_err(|source| Error::io("canonicalizing installation root", source))?;
466 let mut destinations = HashSet::new();
467 for member in artifacts.as_slice() {
468 if !destinations.insert(member.destination.clone()) {
469 return Err(Error::invalid(format!(
470 "duplicate destination: {}",
471 member.destination.display()
472 )));
473 }
474 validate_source(member, &canonical_root, &installation_root)?;
475 }
476 Ok(Self {
477 product,
478 release,
479 installation_root,
480 artifacts,
481 })
482 }
483
484 pub fn product(&self) -> &ProductId {
486 &self.product
487 }
488
489 pub fn release(&self) -> &ReleaseId {
491 &self.release
492 }
493
494 pub fn installation_root(&self) -> &Path {
496 &self.installation_root
497 }
498
499 pub fn artifacts(&self) -> &ArtifactSet {
501 &self.artifacts
502 }
503
504 pub fn destination(&self, member: &MemberId) -> Result<PathBuf> {
506 let artifact = self
507 .artifacts
508 .iter()
509 .find(|candidate| candidate.id == *member)
510 .ok_or_else(|| Error::UnknownMember(member.to_string()))?;
511 Ok(self.installation_root.join(&artifact.destination))
512 }
513
514 pub fn prepare(self) -> Result<PreparedTransaction> {
516 Stage::prepare(self)
517 }
518
519 pub fn prepare_with_bound_sources(self, bound: BoundSources) -> Result<PreparedTransaction> {
533 Stage::prepare_bound(self, bound)
534 }
535
536 #[cfg(test)]
537 pub(crate) fn prepare_with_failure(
538 self,
539 point: crate::test_support::FailurePoint,
540 ) -> Result<PreparedTransaction> {
541 Stage::prepare_with_failure(self, point)
542 }
543}
544
545fn validate_installation_root(root: &Path) -> Result<()> {
546 if !root.is_absolute() {
547 return Err(Error::invalid("installation root must be absolute"));
548 }
549 let metadata = fs::symlink_metadata(root)
550 .map_err(|source| Error::io("reading installation root", source))?;
551 if !metadata.is_dir() {
552 return Err(Error::invalid("installation root must be a directory"));
553 }
554 Ok(())
555}
556
557fn validate_source(
558 member: &ArtifactMember,
559 canonical_root: &Path,
560 installation_root: &Path,
561) -> Result<()> {
562 if !member.source.is_absolute() {
563 return Err(Error::invalid(format!(
564 "source for {} must be absolute",
565 member.id
566 )));
567 }
568 let metadata = fs::symlink_metadata(&member.source)
569 .map_err(|source| Error::io("reading artifact source", source))?;
570 if member.file_kind == FileKind::Regular && !metadata.is_file() {
571 return Err(Error::invalid(format!(
572 "source for {} is not a regular file",
573 member.id
574 )));
575 }
576 let canonical_source = fs::canonicalize(&member.source)
577 .map_err(|source| Error::io("canonicalizing artifact source", source))?;
578 if canonical_source.starts_with(canonical_root) {
579 return Err(Error::invalid(format!(
580 "source for {} is inside the installation root",
581 member.id
582 )));
583 }
584 let destination = installation_root.join(&member.destination);
585 if canonical_source == destination {
586 return Err(Error::invalid(format!(
587 "source for {} aliases its destination",
588 member.id
589 )));
590 }
591 Ok(())
592}
593
594pub(crate) fn normalize_relative_path(path: &Path) -> Result<PathBuf> {
595 if path.as_os_str().is_empty() || path.is_absolute() {
596 return Err(Error::invalid(
597 "destination must be a non-empty relative path",
598 ));
599 }
600 let mut normalized = PathBuf::new();
601 for component in path.components() {
602 match component {
603 Component::Normal(value) => {
604 if value.to_string_lossy().chars().any(char::is_control) {
605 return Err(Error::invalid("destination contains control characters"));
606 }
607 normalized.push(value);
608 }
609 Component::CurDir => {}
610 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
611 return Err(Error::invalid("destination escapes the installation root"));
612 }
613 }
614 }
615 if normalized.as_os_str().is_empty() {
616 return Err(Error::invalid("destination must name a file"));
617 }
618 Ok(normalized)
619}