Skip to main content

strop_workspace/
directory.rs

1//! Pure directory identity and metadata, shared by local, SSH and container views.
2use crate::ResourceLocation;
3use std::path::{Component, Path, PathBuf};
4use std::sync::Arc;
5
6/// One native child component. Display text never becomes path authority.
7#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
8pub struct EntryName(PathBuf);
9#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
10#[error("a directory entry must be one nonempty native name, without NUL or a parent component")]
11pub struct EntryNameError;
12impl EntryName {
13    pub fn new(name: PathBuf) -> Result<Self, EntryNameError> {
14        let mut components = name.components();
15        if !matches!(components.next(), Some(Component::Normal(_)))
16            || components.next().is_some()
17            || name.file_name() != Some(name.as_os_str())
18            || name.as_os_str().as_encoded_bytes().contains(&0)
19        {
20            return Err(EntryNameError);
21        }
22        Ok(Self(name))
23    }
24    pub fn as_path(&self) -> &Path {
25        &self.0
26    }
27    pub fn display(&self) -> String {
28        display_path(&self.0)
29    }
30}
31impl serde::Serialize for EntryName {
32    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
33        strop_core::path_serde::serialize(&self.0, serializer)
34    }
35}
36impl<'de> serde::Deserialize<'de> for EntryName {
37    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
38        Self::new(strop_core::path_serde::deserialize(deserializer)?)
39            .map_err(serde::de::Error::custom)
40    }
41}
42
43/// Lossless display: valid Unicode remains readable; controls, backslashes and
44/// invalid native bytes have distinct visible spellings. This is not an input URI.
45pub fn display_path(path: &Path) -> String {
46    use std::fmt::Write as _;
47    let mut out = String::new();
48    for chunk in path.as_os_str().as_encoded_bytes().utf8_chunks() {
49        for character in chunk.valid().chars() {
50            match character {
51                '\\' => out.push_str("\\\\"),
52                '\n' => out.push_str("\\n"),
53                '\r' => out.push_str("\\r"),
54                '\t' => out.push_str("\\t"),
55                value
56                    if value.is_control()
57                        || matches!(value, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') =>
58                {
59                    let _ = write!(out, "\\u{{{:X}}}", value as u32);
60                }
61                value => out.push(value),
62            }
63        }
64        for byte in chunk.invalid() {
65            let _ = write!(out, "\\x{byte:02X}");
66        }
67    }
68    out
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
72pub enum EntryKind {
73    File,
74    Directory,
75    SymbolicLink,
76    Fifo,
77    Socket,
78    BlockDevice,
79    CharacterDevice,
80    Unknown,
81}
82impl EntryKind {
83    pub const fn marker(self) -> char {
84        match self {
85            Self::File => '-',
86            Self::Directory => 'd',
87            Self::SymbolicLink => 'l',
88            Self::Fifo => 'p',
89            Self::Socket => 's',
90            Self::BlockDevice => 'b',
91            Self::CharacterDevice => 'c',
92            Self::Unknown => '?',
93        }
94    }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
98#[serde(try_from = "u16", into = "u16")]
99pub struct Permissions(u16);
100#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
101#[error("permission bits exceed POSIX access and special bits")]
102pub struct PermissionBitsError;
103impl Permissions {
104    pub const fn new(bits: u16) -> Result<Self, PermissionBitsError> {
105        if bits & !0o7777 != 0 {
106            Err(PermissionBitsError)
107        } else {
108            Ok(Self(bits))
109        }
110    }
111    pub const fn from_mode(mode: u32) -> Self {
112        Self((mode & 0o7777) as u16)
113    }
114    pub const fn bits(self) -> u16 {
115        self.0
116    }
117}
118impl TryFrom<u16> for Permissions {
119    type Error = PermissionBitsError;
120    fn try_from(bits: u16) -> Result<Self, Self::Error> {
121        Self::new(bits)
122    }
123}
124impl From<Permissions> for u16 {
125    fn from(value: Permissions) -> Self {
126        value.bits()
127    }
128}
129impl std::fmt::Display for Permissions {
130    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        for (read, write, execute, special, lower, upper) in [
132            (0o400, 0o200, 0o100, 0o4000, 's', 'S'),
133            (0o040, 0o020, 0o010, 0o2000, 's', 'S'),
134            (0o004, 0o002, 0o001, 0o1000, 't', 'T'),
135        ] {
136            let r = if self.0 & read != 0 { 'r' } else { '-' };
137            let w = if self.0 & write != 0 { 'w' } else { '-' };
138            let x = match (self.0 & execute != 0, self.0 & special != 0) {
139                (true, true) => lower,
140                (false, true) => upper,
141                (true, false) => 'x',
142                (false, false) => '-',
143            };
144            write!(formatter, "{r}{w}{x}")?;
145        }
146        Ok(())
147    }
148}
149
150/// Identity and time observations are facts supplied by the backend, never guessed.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
152pub struct ObjectId {
153    pub device: u64,
154    pub inode: u64,
155}
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
157pub struct FileTime {
158    pub seconds: i64,
159    pub nanos: u32,
160}
161#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
162pub struct Observation {
163    pub kind: EntryKind,
164    pub identity: Option<ObjectId>,
165    pub size: Option<u64>,
166    pub modified: Option<FileTime>,
167    pub changed: Option<FileTime>,
168    pub permissions: Option<Permissions>,
169    pub uid: Option<u32>,
170    pub gid: Option<u32>,
171    pub links: Option<u64>,
172    pub digest: Option<[u8; 32]>,
173}
174impl Observation {
175    pub fn unknown(kind: EntryKind) -> Self {
176        Self {
177            kind,
178            identity: None,
179            size: None,
180            modified: None,
181            changed: None,
182            permissions: None,
183            uid: None,
184            gid: None,
185            links: None,
186            digest: None,
187        }
188    }
189    pub fn same_object(&self, other: &Self) -> bool {
190        self.identity.is_some() && self.identity == other.identity && self.kind == other.kind
191    }
192    /// Compare metadata facts separately from an optional content observation.
193    pub fn same_metadata(&self, other: &Self) -> bool {
194        self.kind == other.kind
195            && self.identity == other.identity
196            && self.size == other.size
197            && self.modified == other.modified
198            && self.changed == other.changed
199            && self.permissions == other.permissions
200            && self.uid == other.uid
201            && self.gid == other.gid
202            && self.links == other.links
203    }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
207pub struct DirectoryEntry {
208    pub name: EntryName,
209    pub observation: Observation,
210    pub error: Option<String>,
211}
212#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
213pub enum ListingState {
214    Complete,
215    Limited { limit: usize },
216    Failed { message: String },
217}
218impl ListingState {
219    pub fn is_complete(&self) -> bool {
220        matches!(self, Self::Complete)
221    }
222}
223#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
224pub struct DirectorySnapshot {
225    pub location: ResourceLocation,
226    pub entries: Arc<[DirectoryEntry]>,
227    pub state: ListingState,
228}
229impl DirectorySnapshot {
230    pub fn location_of(&self, entry: &DirectoryEntry) -> ResourceLocation {
231        ResourceLocation {
232            filesystem: self.location.filesystem.clone(),
233            path: self.location.path.join(entry.name.as_path()),
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    #[test]
242    fn names_are_native_components_not_paths_or_urls() {
243        for rejected in ["", ".", "..", "/absolute", "parent/child", "child/", "a\0b"] {
244            assert!(EntryName::new(rejected.into()).is_err());
245        }
246        for accepted in ["ssh:literal", "-dash", "space name", "line\nbreak", "界"] {
247            let name = EntryName::new(accepted.into()).unwrap();
248            assert_eq!(name.as_path(), Path::new(accepted));
249            assert!(!name.display().contains('\n'));
250        }
251    }
252}