9.2 KiB, 1 run
created by r2848102244:141, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Who may read and write one hosted repository. |
| 2 | //! |
| 3 | //! The list lives beside the repository in the relay's own store and never in |
| 4 | //! the history. Two reasons, one principled and one forced. |
| 5 | //! |
| 6 | //! Principled: the list answers "who may consume this relay's disk and |
| 7 | //! bandwidth", which is a fact about the relay's resources and not about the |
| 8 | //! history. Putting it in the operation graph would make the log an authority |
| 9 | //! over the relay and would entangle every change of access with the operation |
| 10 | //! vocabulary. GitHub's collaborators are a forge fact and not a git fact, and |
| 11 | //! that precedent is the honest one to follow. |
| 12 | //! |
| 13 | //! Forced: a repository the relay cannot read -- the private form, once entries |
| 14 | //! carry an encrypted body -- would have an unenforceable list if the list were |
| 15 | //! inside it, and the relay is the party that must enforce it. |
| 16 | //! |
| 17 | //! The owner is the key that created the repository, and holds every role. A |
| 18 | //! public repository grants [`Role::Pull`] to anyone, signed request or not; |
| 19 | //! everything else is explicit. |
| 20 | |
| 21 | use ore_store::keys::{ |
| 22 | bytes_of, |
| 23 | text_of, |
| 24 | }; |
| 25 | |
| 26 | use oxedyne_fe2o3_core::prelude::*; |
| 27 | use oxedyne_fe2o3_jdat::prelude::*; |
| 28 | |
| 29 | use std::fs; |
| 30 | use std::path::{ |
| 31 | Path, |
| 32 | PathBuf, |
| 33 | }; |
| 34 | |
| 35 | |
| 36 | /// Name of the access list, within a hosted repository's directory. |
| 37 | pub const ACL_FILE: &str = "acl"; |
| 38 | |
| 39 | /// Version of the access list this relay writes. |
| 40 | pub const ACL_VERSION: u64 = 1; |
| 41 | |
| 42 | |
| 43 | /// What one key may do to one hosted repository. |
| 44 | #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] |
| 45 | pub enum Role { |
| 46 | /// May take operations from the relay. |
| 47 | Pull, |
| 48 | /// May give operations to the relay, and take them. A push without a pull |
| 49 | /// would be a peer that can fill a disk and never converge. |
| 50 | Push, |
| 51 | /// May edit this list and the repository's relay policy, and everything a |
| 52 | /// push may do. |
| 53 | Admin, |
| 54 | } |
| 55 | |
| 56 | impl Role { |
| 57 | |
| 58 | /// Returns the name the list is written with. |
| 59 | pub fn name(&self) -> &'static str { |
| 60 | match self { |
| 61 | Self::Pull => "pull", |
| 62 | Self::Push => "push", |
| 63 | Self::Admin => "admin", |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /// Reads a role by name. |
| 68 | pub fn of(name: &str) |
| 69 | -> Outcome<Self> |
| 70 | { |
| 71 | match name { |
| 72 | "pull" => Ok(Self::Pull), |
| 73 | "push" => Ok(Self::Push), |
| 74 | "admin" => Ok(Self::Admin), |
| 75 | other => Err(err!( |
| 76 | "There is no role {:?}. There are three: pull, push, admin.", other; |
| 77 | Invalid, Input)), |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /// Reports whether holding this role is enough to do what `want` asks. |
| 82 | pub fn covers(&self, want: Role) -> bool { |
| 83 | match self { |
| 84 | Self::Admin => true, |
| 85 | Self::Push => want != Role::Admin, |
| 86 | Self::Pull => want == Role::Pull, |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | |
| 92 | /// Who may do what to one hosted repository. |
| 93 | #[derive(Clone, Debug)] |
| 94 | pub struct Acl { |
| 95 | /// Version of the list format. |
| 96 | pub format: u64, |
| 97 | /// The public key that created the repository, which holds every role. |
| 98 | pub owner: Vec<u8>, |
| 99 | /// Whether anybody at all may pull, request signed or not. |
| 100 | pub public: bool, |
| 101 | /// Public key to the role it was granted. |
| 102 | pub grants: Vec<(Vec<u8>, Role)>, |
| 103 | } |
| 104 | |
| 105 | impl Acl { |
| 106 | |
| 107 | /// Constructs the list a fresh repository is created with. |
| 108 | pub fn new(owner: Vec<u8>, public: bool) -> Self { |
| 109 | Self { |
| 110 | format: ACL_VERSION, |
| 111 | owner, |
| 112 | public, |
| 113 | grants: Vec::new(), |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | /// Grants a role to a key, replacing whatever it held before. |
| 118 | pub fn grant(&mut self, public: Vec<u8>, role: Role) { |
| 119 | self.grants.retain(|(k, _)| k != &public); |
| 120 | self.grants.push((public, role)); |
| 121 | self.grants.sort(); |
| 122 | } |
| 123 | |
| 124 | /// Reports whether a key may do what `want` asks. |
| 125 | /// |
| 126 | /// `key` is `None` where the request carried no signature, which only a |
| 127 | /// public repository's pull can survive. |
| 128 | pub fn may(&self, key: Option<&[u8]>, want: Role) -> bool { |
| 129 | if self.public && want == Role::Pull { |
| 130 | return true; |
| 131 | } |
| 132 | let key = match key { |
| 133 | Some(k) => k, |
| 134 | None => return false, |
| 135 | }; |
| 136 | if key == self.owner.as_slice() { |
| 137 | return true; |
| 138 | } |
| 139 | self.grants.iter().any(|(k, role)| k == key && role.covers(want)) |
| 140 | } |
| 141 | |
| 142 | /// Returns the path of the list within a repository's directory. |
| 143 | pub fn path_of(dir: &Path) -> PathBuf { |
| 144 | dir.join(ACL_FILE) |
| 145 | } |
| 146 | |
| 147 | /// Serialises the list to a [`Dat`]. |
| 148 | pub fn to_dat(&self) -> Dat { |
| 149 | let grants: Vec<Dat> = self.grants.iter().map(|(key, role)| Dat::List(vec![ |
| 150 | Dat::Str(text_of(key)), |
| 151 | Dat::Str(fmt!("{}", role.name())), |
| 152 | ])).collect(); |
| 153 | let mut map = DaticleMap::new(); |
| 154 | map.insert(Dat::Str(fmt!("format")), Dat::U64(self.format)); |
| 155 | map.insert(Dat::Str(fmt!("owner")), Dat::Str(text_of(&self.owner))); |
| 156 | map.insert(Dat::Str(fmt!("public")), Dat::Bool(self.public)); |
| 157 | map.insert(Dat::Str(fmt!("grants")), Dat::List(grants)); |
| 158 | Dat::Map(map) |
| 159 | } |
| 160 | |
| 161 | /// Reconstructs the list from a [`Dat`]. |
| 162 | pub fn from_dat(dat: &Dat) |
| 163 | -> Outcome<Self> |
| 164 | { |
| 165 | let map = match dat { |
| 166 | Dat::Map(m) => m, |
| 167 | other => return Err(err!( |
| 168 | "An access list expects a map, got {:?}.", other; |
| 169 | Decode, Input, Mismatch)), |
| 170 | }; |
| 171 | let format = match map.get(&Dat::Str(fmt!("format"))) { |
| 172 | Some(Dat::U64(n)) => *n, |
| 173 | Some(Dat::U32(n)) => *n as u64, |
| 174 | Some(Dat::U8(n)) => *n as u64, |
| 175 | other => return Err(err!( |
| 176 | "An access list's format expects a number, got {:?}.", other; |
| 177 | Decode, Input, Mismatch)), |
| 178 | }; |
| 179 | if format != ACL_VERSION { |
| 180 | return Err(err!( |
| 181 | "An access list declares format version {}, and this relay knows only \ |
| 182 | version {}.", format, ACL_VERSION; |
| 183 | Decode, Input, Version, Mismatch)); |
| 184 | } |
| 185 | let owner = match map.get(&Dat::Str(fmt!("owner"))) { |
| 186 | Some(Dat::Str(s)) => res!(bytes_of(s)), |
| 187 | other => return Err(err!( |
| 188 | "An access list's owner expects a key, got {:?}.", other; |
| 189 | Decode, Input, Mismatch)), |
| 190 | }; |
| 191 | let public = match map.get(&Dat::Str(fmt!("public"))) { |
| 192 | Some(Dat::Bool(b)) => *b, |
| 193 | other => return Err(err!( |
| 194 | "An access list's \"public\" expects true or false, got {:?}.", other; |
| 195 | Decode, Input, Mismatch)), |
| 196 | }; |
| 197 | let listed = match map.get(&Dat::Str(fmt!("grants"))) { |
| 198 | Some(Dat::List(l)) => l, |
| 199 | other => return Err(err!( |
| 200 | "An access list's grants expect a list, got {:?}.", other; |
| 201 | Decode, Input, Mismatch)), |
| 202 | }; |
| 203 | let mut grants = Vec::new(); |
| 204 | for item in listed { |
| 205 | let pair = match item { |
| 206 | Dat::List(l) if l.len() == 2 => l, |
| 207 | other => return Err(err!( |
| 208 | "A grant expects a key and a role, got {:?}.", other; |
| 209 | Decode, Input, Mismatch)), |
| 210 | }; |
| 211 | let key = match &pair[0] { |
| 212 | Dat::Str(s) => res!(bytes_of(s)), |
| 213 | other => return Err(err!( |
| 214 | "A grant's key expects a string, got {:?}.", other; |
| 215 | Decode, Input, Mismatch)), |
| 216 | }; |
| 217 | let role = match &pair[1] { |
| 218 | Dat::Str(s) => res!(Role::of(s)), |
| 219 | other => return Err(err!( |
| 220 | "A grant's role expects a string, got {:?}.", other; |
| 221 | Decode, Input, Mismatch)), |
| 222 | }; |
| 223 | grants.push((key, role)); |
| 224 | } |
| 225 | grants.sort(); |
| 226 | Ok(Self { format, owner, public, grants }) |
| 227 | } |
| 228 | |
| 229 | /// Reads the list from a repository's directory. |
| 230 | pub fn read(dir: &Path) |
| 231 | -> Outcome<Self> |
| 232 | { |
| 233 | let path = Self::path_of(dir); |
| 234 | let text = match fs::read_to_string(&path) { |
| 235 | Ok(t) => t, |
| 236 | Err(e) => return Err(err!(e, |
| 237 | "The access list {:?} could not be read.", path; |
| 238 | IO, File, Read)), |
| 239 | }; |
| 240 | let dat = match Dat::decode_string(text) { |
| 241 | Ok(d) => d, |
| 242 | Err(e) => return Err(err!(e, |
| 243 | "The access list {:?} is not readable JDAT.", path; |
| 244 | Decode, Input)), |
| 245 | }; |
| 246 | Self::from_dat(&dat) |
| 247 | } |
| 248 | |
| 249 | /// Writes the list to a repository's directory. |
| 250 | pub fn write(&self, dir: &Path) |
| 251 | -> Outcome<()> |
| 252 | { |
| 253 | let text = res!(self.to_dat().jdat_to_lines(" ")); |
| 254 | let path = Self::path_of(dir); |
| 255 | match fs::write(&path, fmt!("{}\n", text)) { |
| 256 | Ok(()) => Ok(()), |
| 257 | Err(e) => Err(err!(e, |
| 258 | "The access list {:?} could not be written.", path; |
| 259 | IO, File, Write)), |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | |
| 265 | #[cfg(test)] |
| 266 | mod tests { |
| 267 | use super::*; |
| 268 | |
| 269 | /// A role covers what it implies and nothing above it. |
| 270 | #[test] |
| 271 | fn a_role_covers_what_it_implies() -> Outcome<()> { |
| 272 | assert!(Role::Push.covers(Role::Pull), "a push may take as well as give"); |
| 273 | assert!(!Role::Pull.covers(Role::Push), "a pull may not give"); |
| 274 | assert!(!Role::Push.covers(Role::Admin), "a push may not grant"); |
| 275 | assert!(Role::Admin.covers(Role::Push) && Role::Admin.covers(Role::Pull)); |
| 276 | Ok(()) |
| 277 | } |
| 278 | |
| 279 | /// The owner holds everything, a grant holds what it says, a stranger holds |
| 280 | /// nothing, and a public repository lets anyone pull and nobody push. |
| 281 | #[test] |
| 282 | fn the_list_answers_who_may_do_what() -> Outcome<()> { |
| 283 | let owner = b"owner-key".to_vec(); |
| 284 | let friend = b"friend-key".to_vec(); |
| 285 | let stranger = b"stranger-key".to_vec(); |
| 286 | let mut acl = Acl::new(owner.clone(), false); |
| 287 | acl.grant(friend.clone(), Role::Pull); |
| 288 | assert!(acl.may(Some(&owner), Role::Admin)); |
| 289 | assert!(acl.may(Some(&friend), Role::Pull)); |
| 290 | assert!(!acl.may(Some(&friend), Role::Push)); |
| 291 | assert!(!acl.may(Some(&stranger), Role::Pull)); |
| 292 | assert!(!acl.may(None, Role::Pull), "a private repository answers nobody"); |
| 293 | |
| 294 | // Raising the grant replaces it rather than adding a second. |
| 295 | acl.grant(friend.clone(), Role::Push); |
| 296 | assert_eq!(acl.grants.len(), 1); |
| 297 | assert!(acl.may(Some(&friend), Role::Push)); |
| 298 | |
| 299 | let open = Acl::new(owner.clone(), true); |
| 300 | assert!(open.may(None, Role::Pull), "a public repository answers anyone"); |
| 301 | assert!(!open.may(None, Role::Push), "and still takes nothing from a stranger"); |
| 302 | assert!(!open.may(Some(&stranger), Role::Push)); |
| 303 | |
| 304 | // And it survives the round trip through its file form. |
| 305 | let back = res!(Acl::from_dat(&acl.to_dat())); |
| 306 | assert_eq!(back.owner, acl.owner); |
| 307 | assert_eq!(back.public, acl.public); |
| 308 | assert_eq!(back.grants, acl.grants); |
| 309 | Ok(()) |
| 310 | } |
| 311 | } |