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}