The OG way

One of the key design initiatives for the Rust rewrite is elegancy and modularity.

In the Go rewrite, initially all functions live in a single daemon.go file. This inherits the older design from the now-defunct bash version. But it is not perfect for several reasons:

  • LSP auto-complete cluttered by various functions, constants while implementing new functions.
  • No clear hierarchy between different code. It’s a giant pain to navigate without grep.
  • You can’t disable features at all. Neither compile-time nor runtime.
  • No shared behaviour between features. New features clutters the codebase.
  • Implementing multi-threading is hard and fragmented. Partly because of the above issue, and partly because we have to allocate channels every time we multi-task.
  • No clear configuration sharing mechanism. Passed Config struct has 2 possible forms: copied or pointer.
  • It is very easy to make a typo in bubblewrap command line.

Bind rule

Portable utilises the bubblewrap bind-mounting user executable as a major line of defence. In the old Bash and Go written version, bubblewrap command line is constructed via multiple channels, using slices of strings as underlying transport. Thus it bears an important issue: it is really easy to make a typo!

No worries though! With Rust’s enum types, we can define a subset of supported bubblewrap operations in one enum:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
BindRule represents a single rule of exposing the host system
*/
#[derive(Debug)]
pub enum BindRule {
Path {
source: std::path::PathBuf,
dest: std::path::PathBuf,
class: BindType,
},

/**
Create a symlink at DEST with target SRC
*/
Symlink {
source: std::path::PathBuf,
dest: std::path::PathBuf,
},

/**
The sources are overlaid in the order given,
with the first source on the command line at the bottom of the stack:
if a given path to be read exists in more than one source,
the file is read from the last such source specified.
*/
Overlay {
sources: Vec<std::path::PathBuf>,
dest: std::path::PathBuf,
class: OverlayType,
},

/**
Mount new virtual filesystems on DEST (devtmpfs, etc.)
*/
VirtualFS {
dest: std::path::PathBuf,
class: VirtualFS,
}
}

It’s not hard to guess that we implemented the --{ro-,dev-,}bind in Path variant, and others are quite self-describing. We define a type alias for a collection of BindRule:

1
pub type BindRules = Vec<BindRule>;

The issue of making a typo is then mostly eliminated.

Translating arbitrary Rust types into command line

The above solution works in one direction: it essentially serialises bubblewrap command options into Rust types. But we can’t just pass Rust types when using them, as they expect command-line String arguments.

That is the appropriate point of entry for another Rust feature, traits:

A trait defines the functionality a particular type has and can share with other types. We can use traits to define shared behavior in an abstract way.

We define a trait called ToCmdline, and it’s primary role is to translate arbitrary Rust types into a vector of Strings that other programs know.

1
2
3
4
5
6
7
8
// Trait definition
/**
The trait ToCmdline defines shared behaviour to convert certain rules as command line
arguments.
*/
pub trait ToCmdline {
fn to_cmdline(&self) -> impl std::future::Future<Output = Vec<String>> + Send;
}

The trait has a child function that is asynchronous, takes self type as a mutable borrowed reference and returns a vector of strings that is safe to send across threads. As you see, the idea behind this is beginning to take shape: a shared trait for many types, D-Bus proxy rules, bubblewrap mount options, you name it. All with the power of Rust traits. But these are merely definitions, a skeleton for types to implement. We need to write the actual implementation to make it useable for our Vec<BindRule> type:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
impl ToCmdline for BindRules {
async fn to_cmdline(&self) -> Vec<String> {
let mut ret = vec![];
for rule in self {
match rule {
BindRule::Path { source, dest, class } => {
match class {
BindType::Device => {
ret.push("--dev-bind".to_string());
}
BindType::ReadOnly => {
ret.push("--ro-bind".into());
}
BindType::ReadWrite => {
ret.push("--bind".into());
}
};
ret.push(source.to_string_lossy().into());
ret.push(dest.to_string_lossy().into());
}
BindRule::Symlink { source, dest } => {
ret.push("--symlink".into());
ret.push(source.to_string_lossy().into());
ret.push(dest.to_string_lossy().into());
}
BindRule::Overlay { sources, dest, class } => {
for source in sources {
ret.push("--overlay-src".into());
ret.push(source.to_string_lossy().into());
};
match class {
OverlayType::Ro => {
ret.push("--ro-overlay".into());
}
OverlayType::Tmpfs => {
ret.push("--tmp-overlay".into());
}
OverlayType::ReadWrite { rwsrc, workdir }
=> {
ret.push("--overlay".into());
ret.push(rwsrc.to_string_lossy().into());
ret.push(workdir.to_string_lossy().into());
}
};
ret.push(dest.to_string_lossy().into());
}
BindRule::VirtualFS { dest, class } => {
match class {
VirtualFS::Devtmpfs => {
ret.push("--dev".into());
}
VirtualFS::Procfs => {
ret.push("--proc".into());
}
VirtualFS::Tmpfs { size_mb, perms }
=> {
match size_mb {
Some(v) => {
ret.push("--size".into());
let size = v * 1024 * 1024;
if size == 0 {
ret.push("1".to_string());
} else {
ret.push(size.to_string());
}
}
None => {}
};
match perms {
Some(v) => {
use std::os::unix::fs::PermissionsExt;
ret.push("--perms".into());
ret.push(format!("{:04o}", v.mode()));
}
None => {}
};
ret.push("--tmpfs".into());
}
VirtualFS::Mqueue => {
ret.push("--mqueue".into());
}
};

ret.push(dest.to_string_lossy().into());
}
}
};
ret
}
}

To use it, we just bring the trait in scope, and call .to_cmdline() on it.

That’s it. One implementation for the type, use it anywhere without fragmentation.

Subsystems

While the above part solves typos, we still have multiple issues left to sweep.

As such, we are introducing subsystems in Portable 20 with the power of Rust.

In a nutshell, Portable now split all bind-mounting logic into different “subsystem”s using Rust traits.

The above gives you a glimpse into the powerful nature of traits, it allows us to define shared behaviour between different parts of the code. But subsystems are different: every part of the binding code wants different parameters, and that is vastly different than a BindRule. But, we can instead define a dedicated struct inside each unique part of the code (a subsystem), and implement a shared trait across all of them. The GenerateBind trait is published as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
A generic trait for other subsystems to implement binding generation

Portable's bind rule generation system is divided to multiple subsystems. Each of them may
implement different functions and are generally controlled via Cargo feature switches.

Every subsystem has a unique struct to pass along information.

The Init info struct is returned along with bind rules.

Also, there is a cancel_token used to signal console stream complete, or bus-requested exit.
*/
pub trait GenerateBind {
fn bind(self) -> impl std::future::Future<Output = Result<super::types::BindRules, Self::BindError>> + Send;

type BindError;
}

Take the Audio system for an example, the meta module audio declares the following code:

1
2
3
4
5
6
7
8
9
10
#[cfg(feature = "pulseaudio")]
pub mod pulse;
#[cfg(feature = "pulseaudio")]
pub use pulse::*;

pub struct Audio {
pub logger: crate::logger::LogSender,
pub runtime_dir: std::path::PathBuf,
pub env: crate::envs::holder::HoldChannel,
}

It publishes the pulse module if pulseaudio feature is enabled, and it defines a struct called Audio that includes required fields for Audio subsystem to function. The pulse module implements real PulseAudio server socket binding:

1
2
3
4
5
6
7
impl crate::bind::subsystems::GenerateBind for super::Audio {
type BindError = PulseError;

async fn bind(self) -> Result<crate::bind::types::BindRules, Self::BindError> {
do_something(&self)
}
}

While the subsystem part is done, we still need to spawn those code in order to get something out of it. We just define a vector to contain multi-threading workers (in form of tokio JoinHandles) and push the spawned task inside it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let mut workers = vec![];

#[cfg(feature = "audio")]
{
let audio_bind = audio::Audio {
logger: logger.clone(),
runtime_dir: xdg.runtime.to_path_buf(),
env: env.clone(),
};

workers.push(
tokio::spawn(
async {
audio_bind
.bind()
.await
.map_err(BindError::AudioError)
}
)
);
};

The task is then started in background, and others can be spawned freely in the same formula.

At the end, tasks are drained and returned bind rules are extended into one unified set:

1
2
3
4
5
6
7
8
9
10
11
let mut ret = vec![];

for worker in workers {
ret.extend(
worker
.await
.map_err(BindError::SpawnError)
?
?
);
};

Together with the previous ToCmdline trait, it forms a error-prone, multi-threaded, elegant, modular core of the next generation Portable.