noosphere_cli/native/
paths.rs

1//! Implementation related to the file system layout of a sphere workspace
2
3use anyhow::{anyhow, Result};
4use cid::{multihash::Code, multihash::MultihashDigest, Cid};
5use libipld_core::raw::RawCodec;
6use noosphere_core::data::{Did, MemoIpld};
7use noosphere_storage::base64_encode;
8use std::{
9    fmt::Debug,
10    path::{Path, PathBuf},
11};
12
13use super::extension::infer_file_extension;
14
15/// The name of the "private" sphere folder, similar to a .git folder, that is
16/// used to record and update the structure of a sphere over time
17pub const SPHERE_DIRECTORY: &str = ".sphere";
18
19pub(crate) const STORAGE_DIRECTORY: &str = "storage";
20pub(crate) const CONTENT_DIRECTORY: &str = "content";
21pub(crate) const PEERS_DIRECTORY: &str = "peers";
22pub(crate) const SLUGS_DIRECTORY: &str = "slugs";
23pub(crate) const MOUNT_DIRECTORY: &str = "mount";
24pub(crate) const VERSION_FILE: &str = "version";
25pub(crate) const IDENTITY_FILE: &str = "identity";
26pub(crate) const DEPTH_FILE: &str = "depth";
27pub(crate) const LINK_RECORD_FILE: &str = "link_record";
28
29/// [SpherePaths] record the critical paths within a sphere workspace as
30/// rendered to a typical file system. It is used to ensure that we read from
31/// and write to consistent locations when rendering and updating a sphere as
32/// files on disk.
33///
34/// NOTE: We use hashes to represent internal paths for a couple of reasons,
35/// both related to Windows filesystem limitations:
36///
37///  1. Windows filesystem, in the worst case, only allows 260 character-long
38///     paths
39///  2. Windows does not allow various characters (e.g., ':') in file paths, and
40///     there is no option to escape those characters
41///
42/// Hashing eliminates problem 2 and improves conditions so that we are more
43/// likely to avoid problem 1.
44///
45/// See:
46/// https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry
47/// See also:
48/// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
49#[derive(Clone)]
50pub struct SpherePaths {
51    root: PathBuf,
52    sphere: PathBuf,
53    storage: PathBuf,
54    slugs: PathBuf,
55    content: PathBuf,
56    peers: PathBuf,
57    version: PathBuf,
58    identity: PathBuf,
59    depth: PathBuf,
60}
61
62impl Debug for SpherePaths {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("SpherePaths")
65            .field("root", &self.root)
66            .finish()
67    }
68}
69
70impl SpherePaths {
71    /// Returns true if the given path has a .sphere folder
72    fn has_sphere_directory(path: &Path) -> bool {
73        path.is_absolute() && path.join(SPHERE_DIRECTORY).is_dir()
74    }
75
76    // Root is the path that contains the .sphere folder
77    fn new(root: &Path) -> Self {
78        let sphere = root.join(SPHERE_DIRECTORY);
79
80        Self {
81            root: root.into(),
82            storage: sphere.join(STORAGE_DIRECTORY),
83            content: sphere.join(CONTENT_DIRECTORY),
84            peers: sphere.join(PEERS_DIRECTORY),
85            slugs: sphere.join(SLUGS_DIRECTORY),
86            version: sphere.join(VERSION_FILE),
87            identity: sphere.join(IDENTITY_FILE),
88            depth: sphere.join(DEPTH_FILE),
89            sphere,
90        }
91    }
92
93    /// Initialize [SpherePaths] for a given root path. This has the effect of
94    /// creating the "private" directory hierarchy (starting from
95    /// [SPHERE_DIRECTORY] inside the root).
96    pub async fn initialize(root: &Path) -> Result<Self> {
97        if !root.is_absolute() {
98            return Err(anyhow!(
99                "Must use an absolute path to initialize sphere directories; got {:?}",
100                root
101            ));
102        }
103
104        let paths = Self::new(root);
105
106        std::fs::create_dir_all(&paths.storage)?;
107        std::fs::create_dir_all(&paths.content)?;
108        std::fs::create_dir_all(&paths.peers)?;
109        std::fs::create_dir_all(&paths.slugs)?;
110
111        Ok(paths)
112    }
113
114    /// Attempt to discover an existing workspace root by traversing ancestor
115    /// directories until one is found that contains a [SPHERE_DIRECTORY].
116    #[instrument(level = "trace")]
117    pub fn discover(from: Option<&Path>) -> Option<Self> {
118        trace!("Looking in {:?}", from);
119
120        match from {
121            Some(directory) => {
122                if Self::has_sphere_directory(directory) {
123                    trace!("Found in {:?}!", directory);
124                    Some(Self::new(directory))
125                } else {
126                    Self::discover(directory.parent())
127                }
128            }
129            None => None,
130        }
131    }
132
133    /// The path to the root version file within the local [SPHERE_DIRECTORY]
134    pub fn version(&self) -> &Path {
135        &self.version
136    }
137
138    /// The path to the root identity file within the local [SPHERE_DIRECTORY]
139    pub fn identity(&self) -> &Path {
140        &self.identity
141    }
142
143    /// The path to the root depth file within the local [SPHERE_DIRECTORY]
144    pub fn depth(&self) -> &Path {
145        &self.depth
146    }
147
148    /// The path to the workspace root directory, which contains a
149    /// [SPHERE_DIRECTORY]
150    pub fn root(&self) -> &Path {
151        &self.root
152    }
153
154    /// The path to the [SPHERE_DIRECTORY] within the workspace root
155    pub fn sphere(&self) -> &Path {
156        &self.sphere
157    }
158
159    /// The path the directory within the [SPHERE_DIRECTORY] that contains
160    /// rendered peer spheres
161    pub fn peers(&self) -> &Path {
162        &self.peers
163    }
164
165    /// Given a slug, get a path where we may write a reverse-symlink to a file
166    /// system file that is a rendered equivalent of the content that can be
167    /// found at that slug. The slug's UTF-8 bytes are base64-encoded so that
168    /// certain characters that are allowed in slugs (e.g., '/') do not prevent
169    /// us from creating the symlink.
170    pub fn slug(&self, slug: &str) -> Result<PathBuf> {
171        Ok(self.slugs.join(base64_encode(slug.as_bytes())?))
172    }
173
174    /// Given a peer [Did] and sphere version [Cid], get a path where the that
175    /// peer's sphere at the given version ought to be rendered. The path will
176    /// be unique and deterministic far a given combination of [Did] and [Cid].
177    pub fn peer(&self, peer: &Did, version: &Cid) -> PathBuf {
178        let cid = Cid::new_v1(
179            RawCodec.into(),
180            Code::Blake3_256.digest(&[peer.as_bytes(), &version.to_bytes()].concat()),
181        );
182        self.peers.join(cid.to_string())
183    }
184
185    /// Given a [Cid] for a peer's memo, get a path to a file where the content
186    /// referred to by that memo ought to be written.
187    pub fn peer_hard_link(&self, memo_cid: &Cid) -> PathBuf {
188        self.content.join(memo_cid.to_string())
189    }
190
191    /// Given a slug and a [MemoIpld] referring to some content in the local
192    /// sphere, get a path to a file where the content referred to by the
193    /// [MemoIpld] ought to be rendered (including file extension).
194    pub fn root_hard_link(&self, slug: &str, memo: &MemoIpld) -> Result<PathBuf> {
195        self.file(&self.root, slug, memo)
196    }
197
198    /// Similar to [SpherePaths::root_hard_link] but for a peer given by [Did]
199    /// and sphere version [Cid].
200    pub fn peer_soft_link(
201        &self,
202        peer: &Did,
203        version: &Cid,
204        slug: &str,
205        memo: &MemoIpld,
206    ) -> Result<PathBuf> {
207        self.file(&self.peer(peer, version).join(MOUNT_DIRECTORY), slug, memo)
208    }
209
210    /// Given a base path, a slug and a [MemoIpld], get the full file path
211    /// (including inferred file extension) for a file that corresponds to the
212    /// given [MemoIpld].
213    pub fn file(&self, base: &Path, slug: &str, memo: &MemoIpld) -> Result<PathBuf> {
214        let extension = infer_file_extension(memo)?;
215        let file_fragment = match extension {
216            Some(extension) => [slug, &extension].join("."),
217            None => slug.into(),
218        };
219        Ok(base.join(file_fragment))
220    }
221}