zixcel_github/
artifacts.rs1use base64::{Engine as _, engine::general_purpose::STANDARD};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4
5use crate::ConnectorError;
6
7const TEXT_SCHEMA: &str = "zixcel://github/text-artifact/v1";
8const INPUTS_SCHEMA: &str = "zixcel://github/workflow-inputs-artifact/v1";
9const SNAPSHOT_SCHEMA: &str = "zixcel://github/source-snapshot-artifact/v1";
10const MAX_ARTIFACT_BYTES: usize = 16 * 1024 * 1024;
11
12#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
13#[serde(deny_unknown_fields)]
14pub struct GitHubTextArtifact {
15 pub schema: String,
16 pub text: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
20#[serde(deny_unknown_fields)]
21pub struct GitHubWorkflowInputsArtifact {
22 pub schema: String,
23 pub inputs: BTreeMap<String, String>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
27#[serde(deny_unknown_fields)]
28pub struct GitHubSnapshotArtifact {
29 pub schema: String,
30 pub branch: String,
31 pub message: String,
32 pub files: Vec<GitHubSnapshotFile>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
36#[serde(deny_unknown_fields)]
37pub struct GitHubSnapshotFile {
38 pub path: String,
39 pub mode: GitHubFileMode,
40 pub content_base64: String,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
44pub enum GitHubFileMode {
45 #[serde(rename = "100644")]
46 Regular,
47 #[serde(rename = "100755")]
48 Executable,
49}
50
51impl GitHubFileMode {
52 #[must_use]
53 pub const fn as_str(self) -> &'static str {
54 match self {
55 Self::Regular => "100644",
56 Self::Executable => "100755",
57 }
58 }
59}
60
61pub fn parse_text_artifact(bytes: &[u8]) -> Result<GitHubTextArtifact, ConnectorError> {
67 let value: GitHubTextArtifact = decode(bytes)?;
68 if value.schema != TEXT_SCHEMA || value.text.len() > 65_536 {
69 return Err(invalid());
70 }
71 Ok(value)
72}
73
74pub fn parse_workflow_inputs_artifact(
80 bytes: &[u8],
81) -> Result<GitHubWorkflowInputsArtifact, ConnectorError> {
82 let value: GitHubWorkflowInputsArtifact = decode(bytes)?;
83 if value.schema != INPUTS_SCHEMA
84 || value.inputs.len() > 64
85 || value.inputs.iter().any(|(key, item)| {
86 !token(key, 128)
87 || item.len() > 1024
88 || item
89 .bytes()
90 .any(|byte| byte.is_ascii_control() && !matches!(byte, b'\n' | b'\r' | b'\t'))
91 })
92 {
93 return Err(invalid());
94 }
95 Ok(value)
96}
97
98pub fn parse_snapshot_artifact(bytes: &[u8]) -> Result<GitHubSnapshotArtifact, ConnectorError> {
105 let value: GitHubSnapshotArtifact = decode(bytes)?;
106 if value.schema != SNAPSHOT_SCHEMA
107 || !git_branch(&value.branch)
108 || value.message.is_empty()
109 || value.message.len() > 512
110 || value.files.is_empty()
111 || value.files.len() > 4096
112 {
113 return Err(invalid());
114 }
115 let mut paths = BTreeSet::new();
116 let mut size = 0_usize;
117 for file in &value.files {
118 if !path(&file.path) || !paths.insert(file.path.as_str()) {
119 return Err(invalid());
120 }
121 let content = STANDARD
122 .decode(&file.content_base64)
123 .map_err(|_| invalid())?;
124 size = size.checked_add(content.len()).ok_or_else(invalid)?;
125 if content.len() > 1_048_576 || size > 8 * 1024 * 1024 {
126 return Err(invalid());
127 }
128 }
129 Ok(value)
130}
131
132fn decode<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T, ConnectorError> {
133 if bytes.is_empty() || bytes.len() > MAX_ARTIFACT_BYTES {
134 return Err(invalid());
135 }
136 serde_json::from_slice(bytes).map_err(|_| invalid())
137}
138
139fn token(value: &str, maximum: usize) -> bool {
140 !value.is_empty()
141 && value.len() <= maximum
142 && value.trim() == value
143 && !value.bytes().any(|byte| byte.is_ascii_control())
144}
145
146fn path(value: &str) -> bool {
147 token(value, 512)
148 && !value.contains('\\')
149 && !value
150 .split('/')
151 .any(|part| part.eq_ignore_ascii_case(".git"))
152 && !value.starts_with('/')
153 && !value.ends_with('/')
154 && !value
155 .split('/')
156 .any(|item| item.is_empty() || matches!(item, "." | ".."))
157}
158
159fn invalid() -> ConnectorError {
160 ConnectorError::new("artifact", "invalid GitHub artifact")
161}
162
163pub(crate) fn git_branch(value: &str) -> bool {
164 token(value, 128)
165 && !value.starts_with('-')
166 && !value.starts_with('/')
167 && !value.ends_with('/')
168 && !value.ends_with('.')
169 && !value.contains("..")
170 && !value.contains("@{")
171 && !value.contains("//")
172 && !value.bytes().any(|b| b" ~^:?*[\\".contains(&b))
173 && !value
174 .split('/')
175 .any(|p| p.starts_with('.') || p.ends_with(".lock"))
176}