Skip to main content

qcode/engine/
names.rs

1//! What QCode calls the images, containers and volumes it makes.
2//!
3//! The names are a contract with the engine, not a display: the same workspace and profile always
4//! lead back to the same container, so QCode finds its own work again after a restart. They are
5//! built in one place so that nothing has to spell them out a second time.
6//!
7//! Every part that goes into a name comes from a [`SafeName`](crate::profile::SafeName) or a
8//! [`WorkspaceId`](crate::store::WorkspaceId), both of which hold only characters podman and
9//! docker accept; the tests below prove that what those two can produce is a name the engine
10//! takes, so nothing here has to escape anything.
11
12/// The image every profile image is built on.
13pub const BASE_IMAGE: &str = "qcode/base";
14
15/// The label every profile image carries, whose value is the name of the profile it was built
16/// for.
17///
18/// It is what tells an image of ours from one the person built on the same engine, and it is why
19/// the images QCode takes away for itself are only ever images of ours: an image without this
20/// label is the person's, whatever it is called.
21pub const PROFILE_LABEL: &str = "qcode.profile";
22
23/// The machine name every container QCode creates answers to.
24///
25/// It is one name for all of them, never the engine's random one, because a harness may bake
26/// the machine name into its login. Gemini CLI does: its installed package
27/// (`packages/core/dist/src/services/fileKeychain.js`) encrypts `gemini-credentials.json` with
28/// `deriveEncryptionKey() { const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli`;
29/// return crypto.scryptSync("gemini-cli-oauth", salt, 32); }`. A login made in the sign-in
30/// container is copied into every workspace's home, and it only decrypts there when the sign-in
31/// container, the courier and the workspace container all report the same machine name.
32///
33/// The other half of that salt, the user name, is already the same everywhere: the image's
34/// user is `qcode` at uid 1000 ([`crate::base::paths::USER`]), and podman's `--userns=keep-id`
35/// maps the person onto it. Docker's `--user <uid>:<gid>` path does not: a person whose uid is
36/// not 1000 has no passwd entry in the container, so `os.userInfo()` throws there and the
37/// login is not written at all. That is a separate gap, not closed by this name.
38pub const HOSTNAME: &str = "qcode";
39
40/// The image a profile is installed into.
41#[must_use]
42pub fn profile_image(profile: &str) -> String {
43    format!("qcode/profile/{profile}")
44}
45
46/// The container a workspace's profile lives in.
47#[must_use]
48pub fn profile_container(workspace: &str, profile: &str) -> String {
49    format!("qcode-{workspace}-{profile}")
50}
51
52/// The container a workspace's plain shell lives in.
53#[must_use]
54pub fn base_container(workspace: &str) -> String {
55    format!("qcode-{workspace}-base")
56}
57
58/// The container a sound of a workspace is played in while the tab `tab` plays it.
59///
60/// The dot is what no workspace id and no profile name can hold, so this name can never be the
61/// container of a profile, whatever the profile is called.
62#[must_use]
63pub fn sound_container(workspace: &str, tab: u64) -> String {
64    format!("qcode-{workspace}.play-{tab}")
65}
66
67/// The container the window of a workspace's desktop profile is open in.
68///
69/// One per workspace and profile, not one per tab: the application is single-instance for a home
70/// directory, so a second container on the same home volume would only tell the first to show
71/// itself. The dot is what no workspace id and no profile name can hold, so this can never be the
72/// container the same profile's command-line work would live in.
73#[must_use]
74pub fn desktop_container(workspace: &str, profile: &str) -> String {
75    format!("qcode-{workspace}-{profile}.desk")
76}
77
78/// The short-lived container that writes the bridge's server into the settings on a workspace
79/// profile's home volume, for a profile whose own container cannot be written to from the inside.
80///
81/// The dot is what no workspace id and no profile name can hold, so this can never be the
82/// container of a profile or of its window.
83#[must_use]
84pub fn settings_container(workspace: &str, profile: &str) -> String {
85    format!("qcode-{workspace}-{profile}.mcp")
86}
87
88/// The volume holding a profile's login, shared by every workspace that uses the profile.
89#[must_use]
90pub fn credential_volume(profile: &str) -> String {
91    format!("qcode-cred-{profile}")
92}
93
94/// The volume holding one workspace's copy of a profile's home: its history, memory and settings,
95/// which stay inside that workspace.
96#[must_use]
97pub fn home_volume(workspace: &str, profile: &str) -> String {
98    format!("qcode-home-{workspace}-{profile}")
99}
100
101#[cfg(test)]
102mod tests {
103    use super::{
104        BASE_IMAGE, HOSTNAME, PROFILE_LABEL, base_container, credential_volume, desktop_container, home_volume,
105        profile_container, profile_image, settings_container, sound_container,
106    };
107    use crate::profile::SafeName;
108
109    /// What podman and docker accept as a container or volume name:
110    /// `[a-zA-Z0-9][a-zA-Z0-9_.-]*`.
111    fn is_object_name(name: &str) -> bool {
112        let mut chars = name.chars();
113        chars.next().is_some_and(|first| first.is_ascii_alphanumeric())
114            && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
115    }
116
117    /// What podman and docker accept as an image name: path components of
118    /// `[a-z0-9]+([._-][a-z0-9]+)*` separated by `/`.
119    fn is_image_name(name: &str) -> bool {
120        name.split('/').all(|component| {
121            !component.is_empty()
122                && component.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
123                && component.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
124                && component
125                    .chars()
126                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-'))
127                && !component.contains("--")
128        })
129    }
130
131    fn safe(text: &str) -> SafeName {
132        SafeName::from_display(text).expect("the text has usable characters")
133    }
134
135    #[test]
136    fn turkish_workspace_and_profile_names_still_give_valid_object_names() {
137        let workspace = safe("İstanbul Şubesi");
138        let profile = safe("Günlük Çalışma");
139        assert!(is_image_name(&profile_image(profile.as_str())), "{}", profile_image(profile.as_str()));
140        assert!(is_image_name(BASE_IMAGE));
141        for object in [
142            profile_container(workspace.as_str(), profile.as_str()),
143            base_container(workspace.as_str()),
144            credential_volume(profile.as_str()),
145            home_volume(workspace.as_str(), profile.as_str()),
146        ] {
147            assert!(is_object_name(&object), "{object}");
148        }
149    }
150
151    #[test]
152    fn every_name_a_safe_name_can_produce_is_accepted_by_the_engine() {
153        for text in ["a", "9lives", "a-b-c", &"ab ".repeat(50), "v2.0/build", "IŞIK", "_x_"] {
154            let single = safe(text);
155            let name = single.as_str();
156            assert!(is_image_name(&profile_image(name)), "{text:?}");
157            assert!(is_object_name(&profile_container(name, name)), "{text:?}");
158            assert!(is_object_name(&home_volume(name, name)), "{text:?}");
159            assert!(is_object_name(&credential_volume(name)), "{text:?}");
160            assert!(is_object_name(&base_container(name)), "{text:?}");
161            assert!(is_object_name(&sound_container(name, 7)), "{text:?}");
162            assert!(is_object_name(&desktop_container(name, name)), "{text:?}");
163            assert!(is_object_name(&settings_container(name, name)), "{text:?}");
164            // No profile, whatever its name, has the container a sound plays in or a window opens
165            // in: both are told apart by a dot, which a safe name never holds.
166            assert!(!profile_container(name, name).contains('.'), "{text:?}");
167            assert_ne!(desktop_container(name, name), profile_container(name, name), "{text:?}");
168            assert_ne!(settings_container(name, name), desktop_container(name, name), "{text:?}");
169            assert_ne!(settings_container(name, name), profile_container(name, name), "{text:?}");
170        }
171    }
172
173    #[test]
174    fn the_machine_name_is_one_the_engines_and_a_resolver_take() {
175        // RFC 1123 host label: letters, digits and dashes, neither at the ends.
176        assert!(is_object_name(HOSTNAME) && !HOSTNAME.contains(['_', '.']) && !HOSTNAME.ends_with('-'));
177    }
178
179    #[test]
180    fn the_names_are_the_ones_the_design_settled_on() {
181        assert_eq!(BASE_IMAGE, "qcode/base");
182        assert_eq!(HOSTNAME, "qcode");
183        assert_eq!(PROFILE_LABEL, "qcode.profile");
184        assert_eq!(profile_image("claude-sub"), "qcode/profile/claude-sub");
185        assert_eq!(profile_container("my-app", "claude-sub"), "qcode-my-app-claude-sub");
186        assert_eq!(base_container("my-app"), "qcode-my-app-base");
187        assert_eq!(credential_volume("claude-sub"), "qcode-cred-claude-sub");
188        assert_eq!(home_volume("my-app", "claude-sub"), "qcode-home-my-app-claude-sub");
189        assert_eq!(sound_container("my-app", 3), "qcode-my-app.play-3");
190        assert_eq!(desktop_container("my-app", "antigravity"), "qcode-my-app-antigravity.desk");
191    }
192}