michal/tit
Browse tree · Show commit · Download archive
Diff
b4997462d711 → 5b357f28b44b
README.md
Mode 100644 → 100644; object 20083a337182 → 1e9f5f1ca5c9
@@ -362,3 +362,17 @@ query validation, and the representative index workload. Read the [bounded metadata search architectural decision record](docs/adr/0020-bounded-metadata-search.md) for the limits, authorization, and index decision. + +## Milestone 4.6 gate + +Install stock OpenSSH. Then, run the SSH issue command gate: + +```text +./scripts/check-m4-6 +``` + +This command tests issue creation and listing with human and JSON output. It +tests owner and reader permission, hidden private repositories, suspended +account access, invalid input, raw Markdown storage, and atomic issue events. +Read the [SSH issue command architectural decision record](docs/adr/0021-ssh-issue-commands.md) +for the input, output, authorization, and error contracts.
docs/adr/0021-ssh-issue-commands.md
Mode → 100644; object → 9625696ed942
@@ -1,0 +1,109 @@
+# Architectural decision record 0021: SSH issue commands
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+An account needs to list and create issues without a browser. The SSH public
+key must select the account. The SSH commands must use the same issue service
+and authorization rules as the Web UI. A script also needs output that does not
+depend on human text.
+
+## Decision
+
+Accept these SSH commands:
+
+```text
+issue list OWNER/REPOSITORY [--output human|json]
+issue create OWNER/REPOSITORY [--output human|json]
+```
+
+Limit each command to 512 ASCII bytes. Reject control characters, unknown
+words, missing values, repeated options, invalid targets, and unsupported
+output values. Do not use a shell tokenizer or pass command data to a process.
+
+The create command reads UTF-8 content from standard input. The first line is
+the issue title. All content after the first newline is the plain-text Markdown
+body. A title can have a maximum of 200 bytes. A body can have a maximum of 256
+KiB. Limit the complete input before UTF-8 parsing. The body can be empty.
+
+The list command returns a maximum of 1,000 issues in decreasing issue-number
+order. Human output has one issue on each line:
+
+```text
+#12 open Correct the backup check
+```
+
+The create command returns this human output:
+
+```text
+Created issue alice/project#12.
+```
+
+JSON output is one UTF-8 line. A list result has this form:
+
+```json
+{"version":1,"status":"success","repository":{"owner":"alice","name":"project"},"issues":[{"number":12,"title":"Correct the backup check","state":"open","author":"alice","created_at":1784800000,"updated_at":1784800000}]}
+```
+
+A create result has this form:
+
+```json
+{"version":1,"status":"success","repository":{"owner":"alice","name":"project"},"issue":{"number":12,"title":"Correct the backup check","state":"open","author":"alice","created_at":1784800000,"updated_at":1784800000}}
+```
+
+A failed result has this form:
+
+```json
+{"version":1,"status":"error","error":{"code":"permission-denied"}}
+```
+
+Version 1 error codes are `invalid-command`, `invalid-input`, `invalid-target`,
+`repository-unavailable`, `permission-denied`, `account-unavailable`,
+`service-unavailable`, and `issue-command-failed`. Send JSON results on
+standard output. Send human errors on standard error. Return exit status zero
+for success and one for failure.
+
+Call `IssueService::list` and `IssueService::create` from a bounded blocking
+job. The service applies the common validation and repository authorization.
+Issue creation stores the issue and its `issue-created` event in one SQLite
+transaction.
+
+## Failure and threat cases
+
+The SSH username does not select the account. The authenticated public key
+selects the account. Check the current key identity before the create operation
+starts. The store also checks the current account state and repository role.
+
+Return `repository-unavailable` for a missing repository and for a repository
+that the account cannot read. Thus, the command does not disclose a private
+repository. A reader can list and create issues because the Web UI gives the
+same permission to this role. A suspended account cannot use its repository
+role or create an issue.
+
+Read create input only for a valid create command. Stop and close the channel
+when the input exceeds its limit. Do not interpret title or body content as a
+shell language. Serialize JSON with `serde_json` so user content cannot change
+the output structure.
+
+## Evidence
+
+The production server test uses stock OpenSSH. It creates an issue as an owner,
+lists it in human and JSON modes, and creates an issue as a reader. It proves
+that an unrelated account cannot list a private repository and that a suspended
+account cannot list it. It also covers invalid create input and the versioned
+error output.
+
+The database assertions prove that both commands use the canonical issue
+records. They prove exact Markdown preservation, the authenticated authors,
+monotonic issue numbers, and one `issue-created` event for each successful
+create operation.
+
+## Consequences
+
+The SSH interface has useful issue operations without a shell or a second
+authorization implementation. A client must send the title and body on
+standard input. A later command version can add fields without changing
+version 1.
scripts/check-m4-6
Mode → 100755; object → 395179287d47
@@ -1,0 +1,5 @@ +#!/bin/sh +set -eu + +./scripts/check +cargo test --locked --release --test serve creates_owned_repositories_with_stable_ssh_command_output
src/issue.rs
Mode 100644 → 100644; object 761ea5ff0bdf → fb9d63952072
@@ -9,8 +9,8 @@
IssueChange, IssueDetail, IssueRecord, NewIssue, RepositoryRecord, Store, StoreError,
};
-const MAX_TITLE_BYTES: usize = 200;
-const MAX_BODY_BYTES: usize = 256 * 1024;
+pub(crate) const MAX_TITLE_BYTES: usize = 200;
+pub(crate) const MAX_BODY_BYTES: usize = 256 * 1024;
const MAX_LABEL_BYTES: usize = 80;
#[derive(Clone)]
src/ssh.rs
Mode 100644 → 100644; object 2ca35dbe3884 → c616c70b4e54
@@ -20,6 +20,7 @@
use crate::git::receive_pack::{ReceivePack, ReceivePackError};
use crate::git::transport::{GitRepositories, GitSshService};
use crate::git::upload_pack::{ProtocolVersion, UploadPack, UploadPackError};
+use crate::issue::{IssueError, IssueService, MAX_BODY_BYTES, MAX_TITLE_BYTES};
use crate::policy::RepositoryOperation;
use crate::repository::{RepositoryService, RepositoryServiceError};
@@ -27,8 +28,12 @@
const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
const MAX_RECEIVE_PACK_BYTES: u64 = 128 * 1024 * 1024;
const MAX_REPOSITORY_COMMAND_BYTES: usize = 512;
+const MAX_ISSUE_COMMAND_BYTES: usize = 512;
+const MAX_ISSUE_INPUT_BYTES: usize = MAX_TITLE_BYTES + 1 + MAX_BODY_BYTES;
const REPOSITORY_CREATE_USAGE: &str =
"repo create NAME [--object-format sha1|sha256] [--output human|json]";
+const ISSUE_LIST_USAGE: &str = "issue list OWNER/REPOSITORY [--output human|json]";
+const ISSUE_CREATE_USAGE: &str = "issue create OWNER/REPOSITORY [--output human|json]";
pub(crate) struct RunningSshServer {
address: SocketAddr,
@@ -297,7 +302,7 @@
audit: Arc::clone(&self.audit),
repositories: self.repositories.clone(),
protocol: ProtocolVersion::V0,
- git_channels: HashMap::new(),
+ exec_channels: HashMap::new(),
authenticated_identity: None,
authenticated_key: None,
authenticated_writer: false,
@@ -311,15 +316,24 @@
audit: Arc<RequestAudit>,
repositories: Option<Arc<GitRepositories>>,
protocol: ProtocolVersion,
- git_channels: HashMap<ChannelId, GitChannel>,
+ exec_channels: HashMap<ChannelId, ExecChannel>,
authenticated_identity: Option<SshIdentity>,
authenticated_key: Option<PublicKey>,
authenticated_writer: bool,
}
-enum GitChannel {
+enum ExecChannel {
Upload(Box<UploadChannel>),
Receive(Box<ReceiveChannel>),
+ IssueCreate(IssueCreateChannel),
+}
+
+struct IssueCreateChannel {
+ actor: String,
+ owner: String,
+ repository: String,
+ output: CommandOutput,
+ input: Vec<u8>,
}
struct UploadChannel {
@@ -512,6 +526,41 @@
Err(()) => Err(RepositoryCommandError::Usage),
};
send_repository_command_result(channel, result, machine_requested, session)?;
+ } else if is_issue_command(command) {
+ self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+ session.channel_success(channel)?;
+ let machine_requested = requests_json(command);
+ match (parse_issue_command(command), self.active_identity()) {
+ (Ok(IssueCommand::List(command)), Some(identity)) => {
+ let result =
+ run_issue_list(self.repositories.clone(), identity.username, command).await;
+ send_issue_list_result(channel, result, machine_requested, session)?;
+ }
+ (Ok(IssueCommand::Create(command)), Some(identity)) => {
+ self.exec_channels.insert(
+ channel,
+ ExecChannel::IssueCreate(IssueCreateChannel {
+ actor: identity.username,
+ owner: command.owner,
+ repository: command.repository,
+ output: command.output,
+ input: Vec::new(),
+ }),
+ );
+ }
+ (Ok(_), None) => send_issue_error(
+ channel,
+ IssueCommandError::Unavailable,
+ machine_requested,
+ session,
+ )?,
+ (Err(()), _) => send_issue_error(
+ channel,
+ IssueCommandError::Usage,
+ machine_requested,
+ session,
+ )?,
+ }
} else {
let service = self.open_git_service(command).await;
if let Some(service) = service {
@@ -523,9 +572,9 @@
advertisement,
} => {
session.data(channel, advertisement)?;
- self.git_channels.insert(
+ self.exec_channels.insert(
channel,
- GitChannel::Upload(Box::new(UploadChannel {
+ ExecChannel::Upload(Box::new(UploadChannel {
service: *service,
request: Vec::new(),
})),
@@ -544,9 +593,9 @@
let pack = tokio::fs::File::create(service.incoming_pack()).await;
match pack {
Ok(pack) => {
- self.git_channels.insert(
+ self.exec_channels.insert(
channel,
- GitChannel::Receive(Box::new(ReceiveChannel {
+ ExecChannel::Receive(Box::new(ReceiveChannel {
service: *service,
owner,
repository,
@@ -579,12 +628,26 @@
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
- let Some(git) = self.git_channels.remove(&channel) else {
+ let Some(exec) = self.exec_channels.remove(&channel) else {
return Ok(());
};
- let mut git = match git {
- GitChannel::Upload(git) => git,
- GitChannel::Receive(mut git) => {
+ let mut git = match exec {
+ ExecChannel::IssueCreate(mut issue) => {
+ if append_issue_input(&mut issue.input, data).is_err() {
+ send_issue_error(
+ channel,
+ IssueCommandError::Input,
+ issue.output == CommandOutput::Json,
+ session,
+ )?;
+ } else {
+ self.exec_channels
+ .insert(channel, ExecChannel::IssueCreate(issue));
+ }
+ return Ok(());
+ }
+ ExecChannel::Upload(git) => git,
+ ExecChannel::Receive(mut git) => {
if receive_data(&mut git, data).await.is_err() {
fail_git_channel(channel, session)?;
} else if git.commands_complete
@@ -596,7 +659,8 @@
session,
)?;
} else {
- self.git_channels.insert(channel, GitChannel::Receive(git));
+ self.exec_channels
+ .insert(channel, ExecChannel::Receive(git));
}
return Ok(());
}
@@ -611,7 +675,7 @@
Ok(packets) => packets,
Err(super::git::packetline::PacketLineError::TruncatedHeader)
| Err(super::git::packetline::PacketLineError::TruncatedPacket) => {
- self.git_channels.insert(channel, GitChannel::Upload(git));
+ self.exec_channels.insert(channel, ExecChannel::Upload(git));
return Ok(());
}
Err(_) => {
@@ -638,12 +702,12 @@
Some((_, Err(_))) | None => fail_git_channel(channel, session)?,
}
} else {
- self.git_channels.insert(channel, GitChannel::Upload(git));
+ self.exec_channels.insert(channel, ExecChannel::Upload(git));
}
}
ProtocolVersion::V2 => {
if packets.last() != Some(&Packet::Flush) {
- self.git_channels.insert(channel, GitChannel::Upload(git));
+ self.exec_channels.insert(channel, ExecChannel::Upload(git));
return Ok(());
}
let fetch = packets.iter().any(
@@ -659,7 +723,7 @@
finish_git_channel(channel, 0, session)?;
} else {
git.request.clear();
- self.git_channels.insert(channel, GitChannel::Upload(git));
+ self.exec_channels.insert(channel, ExecChannel::Upload(git));
}
}
Some((_, Err(_))) | None => fail_git_channel(channel, session)?,
@@ -674,7 +738,7 @@
channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
- self.git_channels.remove(&channel);
+ self.exec_channels.remove(&channel);
Ok(())
}
@@ -683,15 +747,29 @@
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
- let Some(GitChannel::Receive(git)) = self.git_channels.remove(&channel) else {
- return Ok(());
- };
- if !git.commands_complete {
- fail_git_channel(channel, session)?;
- return Ok(());
+ match self.exec_channels.remove(&channel) {
+ Some(ExecChannel::Receive(git)) => {
+ if !git.commands_complete {
+ fail_git_channel(channel, session)?;
+ return Ok(());
+ }
+ let result = finish_receive(self.repositories.clone(), git).await;
+ send_receive_result(channel, result, session)?;
+ }
+ Some(ExecChannel::IssueCreate(issue)) => {
+ let active = self
+ .active_identity()
+ .is_some_and(|identity| identity.username == issue.actor);
+ let machine_requested = issue.output == CommandOutput::Json;
+ let result = if active {
+ run_issue_create(self.repositories.clone(), issue).await
+ } else {
+ Err(IssueCommandError::Unavailable)
+ };
+ send_issue_create_result(channel, result, machine_requested, session)?;
+ }
+ Some(ExecChannel::Upload(_)) | None => {}
}
- let result = finish_receive(self.repositories.clone(), git).await;
- send_receive_result(channel, result, session)?;
Ok(())
}
@@ -737,8 +815,8 @@
}
}
-#[derive(Clone, Copy)]
-enum RepositoryCommandOutput {
+#[derive(Clone, Copy, Eq, PartialEq)]
+enum CommandOutput {
Human,
Json,
}
@@ -746,7 +824,7 @@
struct RepositoryCreateCommand {
slug: String,
object_format: gix::hash::Kind,
- output: RepositoryCommandOutput,
+ output: CommandOutput,
}
fn is_repository_command(command: &[u8]) -> bool {
@@ -792,8 +870,8 @@
}
"--output" if output.is_none() => {
output = Some(match value {
- "human" => RepositoryCommandOutput::Human,
- "json" => RepositoryCommandOutput::Json,
+ "human" => CommandOutput::Human,
+ "json" => CommandOutput::Json,
_ => return Err(()),
});
}
@@ -803,7 +881,7 @@
Ok(RepositoryCreateCommand {
slug,
object_format: object_format.unwrap_or(gix::hash::Kind::Sha1),
- output: output.unwrap_or(RepositoryCommandOutput::Human),
+ output: output.unwrap_or(CommandOutput::Human),
})
}
@@ -817,7 +895,7 @@
repositories: Option<Arc<GitRepositories>>,
actor: String,
command: RepositoryCreateCommand,
-) -> Result<(crate::store::RepositoryRecord, RepositoryCommandOutput), RepositoryCommandError> {
+) -> Result<(crate::store::RepositoryRecord, CommandOutput), RepositoryCommandError> {
let repositories = repositories.ok_or(RepositoryCommandError::Unavailable)?;
let database = repositories
.push_database()
@@ -848,15 +926,12 @@
fn send_repository_command_result(
channel: ChannelId,
- result: Result<
- (crate::store::RepositoryRecord, RepositoryCommandOutput),
- RepositoryCommandError,
- >,
+ result: Result<(crate::store::RepositoryRecord, CommandOutput), RepositoryCommandError>,
machine_requested: bool,
session: &mut Session,
) -> Result<(), russh::Error> {
match result {
- Ok((repository, RepositoryCommandOutput::Human)) => {
+ Ok((repository, CommandOutput::Human)) => {
session.data(
channel,
format!(
@@ -867,7 +942,7 @@
)?;
finish_git_channel(channel, 0, session)
}
- Ok((repository, RepositoryCommandOutput::Json)) => {
+ Ok((repository, CommandOutput::Json)) => {
session.data(
channel,
format!(
@@ -926,6 +1001,358 @@
"account-unavailable" => "The account is not active.".to_owned(),
"service-unavailable" => "The repository service is not available.".to_owned(),
_ => "The repository could not be created.".to_owned(),
+ }
+}
+
+enum IssueCommand {
+ List(IssueListCommand),
+ Create(IssueCreateCommand),
+}
+
+struct IssueListCommand {
+ owner: String,
+ repository: String,
+ output: CommandOutput,
+}
+
+struct IssueCreateCommand {
+ owner: String,
+ repository: String,
+ output: CommandOutput,
+}
+
+struct IssueListResult {
+ repository: crate::store::RepositoryRecord,
+ issues: Vec<crate::store::IssueRecord>,
+ output: CommandOutput,
+}
+
+struct IssueCreateResult {
+ owner: String,
+ repository: String,
+ issue: crate::store::IssueRecord,
+ output: CommandOutput,
+}
+
+enum IssueCommandError {
+ Usage,
+ Input,
+ Unavailable,
+ Service(IssueError),
+}
+
+fn is_issue_command(command: &[u8]) -> bool {
+ command == b"issue" || command.starts_with(b"issue ")
+}
+
+fn parse_issue_command(command: &[u8]) -> Result<IssueCommand, ()> {
+ if command.len() > MAX_ISSUE_COMMAND_BYTES || !command.is_ascii() {
+ return Err(());
+ }
+ let command = std::str::from_utf8(command).map_err(|_| ())?;
+ if command
+ .bytes()
+ .any(|byte| byte.is_ascii_control() && byte != b' ')
+ {
+ return Err(());
+ }
+ let mut tokens = command.split_ascii_whitespace();
+ if tokens.next() != Some("issue") {
+ return Err(());
+ }
+ let operation = tokens.next().ok_or(())?;
+ let target = tokens.next().ok_or(())?;
+ let (owner, repository) = target.split_once('/').ok_or(())?;
+ if owner.is_empty() || repository.is_empty() || repository.contains('/') {
+ return Err(());
+ }
+ let output = parse_output_options(tokens)?;
+ match operation {
+ "list" => Ok(IssueCommand::List(IssueListCommand {
+ owner: owner.to_owned(),
+ repository: repository.to_owned(),
+ output,
+ })),
+ "create" => Ok(IssueCommand::Create(IssueCreateCommand {
+ owner: owner.to_owned(),
+ repository: repository.to_owned(),
+ output,
+ })),
+ _ => Err(()),
+ }
+}
+
+fn parse_output_options<'a>(
+ mut tokens: impl Iterator<Item = &'a str>,
+) -> Result<CommandOutput, ()> {
+ let mut output = None;
+ while let Some(option) = tokens.next() {
+ let value = tokens.next().ok_or(())?;
+ if option != "--output" || output.is_some() {
+ return Err(());
+ }
+ output = Some(match value {
+ "human" => CommandOutput::Human,
+ "json" => CommandOutput::Json,
+ _ => return Err(()),
+ });
+ }
+ Ok(output.unwrap_or(CommandOutput::Human))
+}
+
+async fn run_issue_list(
+ repositories: Option<Arc<GitRepositories>>,
+ actor: String,
+ command: IssueListCommand,
+) -> Result<IssueListResult, IssueCommandError> {
+ let repositories = repositories.ok_or(IssueCommandError::Unavailable)?;
+ let database = repositories
+ .push_database()
+ .ok_or(IssueCommandError::Unavailable)?
+ .to_owned();
+ let permit = repositories
+ .blocking_permit()
+ .await
+ .map_err(|_| IssueCommandError::Unavailable)?;
+ tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ IssueService::new(&database)
+ .list(&command.owner, &command.repository, Some(&actor))
+ .map(|(repository, issues)| IssueListResult {
+ repository,
+ issues,
+ output: command.output,
+ })
+ .map_err(IssueCommandError::Service)
+ })
+ .await
+ .map_err(|_| IssueCommandError::Unavailable)?
+}
+
+async fn run_issue_create(
+ repositories: Option<Arc<GitRepositories>>,
+ command: IssueCreateChannel,
+) -> Result<IssueCreateResult, IssueCommandError> {
+ let (title, body) = parse_issue_input(&command.input)?;
+ let repositories = repositories.ok_or(IssueCommandError::Unavailable)?;
+ let database = repositories
+ .push_database()
+ .ok_or(IssueCommandError::Unavailable)?
+ .to_owned();
+ let permit = repositories
+ .blocking_permit()
+ .await
+ .map_err(|_| IssueCommandError::Unavailable)?;
+ tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ IssueService::new(&database)
+ .create(
+ &command.owner,
+ &command.repository,
+ &command.actor,
+ &title,
+ &body,
+ )
+ .map(|issue| IssueCreateResult {
+ owner: command.owner,
+ repository: command.repository,
+ issue,
+ output: command.output,
+ })
+ .map_err(IssueCommandError::Service)
+ })
+ .await
+ .map_err(|_| IssueCommandError::Unavailable)?
+}
+
+fn parse_issue_input(input: &[u8]) -> Result<(String, String), IssueCommandError> {
+ let (title, body) = input
+ .iter()
+ .position(|byte| *byte == b'\n')
+ .map_or((input, &[][..]), |end| (&input[..end], &input[end + 1..]));
+ let title = title.strip_suffix(b"\r").unwrap_or(title);
+ Ok((
+ std::str::from_utf8(title)
+ .map_err(|_| IssueCommandError::Input)?
+ .to_owned(),
+ std::str::from_utf8(body)
+ .map_err(|_| IssueCommandError::Input)?
+ .to_owned(),
+ ))
+}
+
+fn append_issue_input(input: &mut Vec<u8>, data: &[u8]) -> Result<(), ()> {
+ if input.len().saturating_add(data.len()) > MAX_ISSUE_INPUT_BYTES {
+ return Err(());
+ }
+ input.extend_from_slice(data);
+ Ok(())
+}
+
+fn send_issue_list_result(
+ channel: ChannelId,
+ result: Result<IssueListResult, IssueCommandError>,
+ machine_requested: bool,
+ session: &mut Session,
+) -> Result<(), russh::Error> {
+ match result {
+ Ok(result) => {
+ let data = match result.output {
+ CommandOutput::Human => issue_list_human(&result),
+ CommandOutput::Json => issue_list_json(&result),
+ };
+ session.data(channel, data)?;
+ finish_git_channel(channel, 0, session)
+ }
+ Err(error) => send_issue_error(channel, error, machine_requested, session),
+ }
+}
+
+fn issue_list_human(result: &IssueListResult) -> Vec<u8> {
+ if result.issues.is_empty() {
+ return b"No issues.\n".to_vec();
+ }
+ let mut output = String::new();
+ for issue in &result.issues {
+ output.push_str(&format!(
+ "#{} {} {}\n",
+ issue.number, issue.state, issue.title
+ ));
+ }
+ output.into_bytes()
+}
+
+fn issue_list_json(result: &IssueListResult) -> Vec<u8> {
+ let issues = result
+ .issues
+ .iter()
+ .map(|issue| {
+ serde_json::json!({
+ "number": issue.number,
+ "title": issue.title,
+ "state": issue.state,
+ "author": issue.author,
+ "created_at": issue.created_at,
+ "updated_at": issue.updated_at,
+ })
+ })
+ .collect::<Vec<_>>();
+ json_line(serde_json::json!({
+ "version": 1,
+ "status": "success",
+ "repository": {
+ "owner": result.repository.owner,
+ "name": result.repository.slug,
+ },
+ "issues": issues,
+ }))
+}
+
+fn send_issue_create_result(
+ channel: ChannelId,
+ result: Result<IssueCreateResult, IssueCommandError>,
+ machine_requested: bool,
+ session: &mut Session,
+) -> Result<(), russh::Error> {
+ match result {
+ Ok(result) => {
+ let data = match result.output {
+ CommandOutput::Human => format!(
+ "Created issue {}/{}#{}.\n",
+ result.owner, result.repository, result.issue.number
+ )
+ .into_bytes(),
+ CommandOutput::Json => json_line(serde_json::json!({
+ "version": 1,
+ "status": "success",
+ "repository": {
+ "owner": result.owner,
+ "name": result.repository,
+ },
+ "issue": {
+ "number": result.issue.number,
+ "title": result.issue.title,
+ "state": result.issue.state,
+ "author": result.issue.author,
+ "created_at": result.issue.created_at,
+ "updated_at": result.issue.updated_at,
+ },
+ })),
+ };
+ session.data(channel, data)?;
+ finish_git_channel(channel, 0, session)
+ }
+ Err(error) => send_issue_error(channel, error, machine_requested, session),
+ }
+}
+
+fn send_issue_error(
+ channel: ChannelId,
+ error: IssueCommandError,
+ machine_requested: bool,
+ session: &mut Session,
+) -> Result<(), russh::Error> {
+ if machine_requested {
+ session.data(
+ channel,
+ json_line(serde_json::json!({
+ "version": 1,
+ "status": "error",
+ "error": { "code": issue_command_error_code(&error) },
+ })),
+ )?;
+ } else {
+ session.extended_data(
+ channel,
+ 1,
+ format!("tit: {}\n", issue_command_error_message(&error)).into_bytes(),
+ )?;
+ }
+ finish_git_channel(channel, 1, session)
+}
+
+fn json_line(value: serde_json::Value) -> Vec<u8> {
+ let mut output = serde_json::to_vec(&value).expect("a JSON value can be serialized");
+ output.push(b'\n');
+ output
+}
+
+fn issue_command_error_code(error: &IssueCommandError) -> &'static str {
+ match error {
+ IssueCommandError::Usage => "invalid-command",
+ IssueCommandError::Input
+ | IssueCommandError::Service(IssueError::Title | IssueError::Body) => "invalid-input",
+ IssueCommandError::Service(IssueError::Auth(_) | IssueError::RepositoryName(_)) => {
+ "invalid-target"
+ }
+ IssueCommandError::Service(IssueError::Store(
+ crate::store::StoreError::RepositoryNotFound(_, _)
+ | crate::store::StoreError::IssueHidden,
+ )) => "repository-unavailable",
+ IssueCommandError::Service(IssueError::Store(crate::store::StoreError::IssueDenied)) => {
+ "permission-denied"
+ }
+ IssueCommandError::Service(IssueError::Store(
+ crate::store::StoreError::AccountNotFound(_),
+ )) => "account-unavailable",
+ IssueCommandError::Unavailable => "service-unavailable",
+ IssueCommandError::Service(_) => "issue-command-failed",
+ }
+}
+
+fn issue_command_error_message(error: &IssueCommandError) -> String {
+ match issue_command_error_code(error) {
+ "invalid-command" => format!("usage: {ISSUE_LIST_USAGE} or {ISSUE_CREATE_USAGE}"),
+ "invalid-input" => {
+ "The first input line must be a valid title. The remaining input is the body."
+ .to_owned()
+ }
+ "invalid-target" => "The repository name is not valid.".to_owned(),
+ "repository-unavailable" => "The repository is not available.".to_owned(),
+ "permission-denied" => "The account cannot create an issue in this repository.".to_owned(),
+ "account-unavailable" => "The account is not active.".to_owned(),
+ "service-unavailable" => "The issue service is not available.".to_owned(),
+ _ => "The issue command could not be completed.".to_owned(),
}
}
@@ -1214,4 +1641,37 @@
pub(crate) rejected_pty: usize,
pub(crate) rejected_agent: usize,
pub(crate) rejected_forward: usize,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_only_bounded_issue_commands_and_input() {
+ assert!(matches!(
+ parse_issue_command(b"issue list alice/project --output json"),
+ Ok(IssueCommand::List(_))
+ ));
+ assert!(matches!(
+ parse_issue_command(b"issue create alice/project"),
+ Ok(IssueCommand::Create(_))
+ ));
+ for command in [
+ b"issue list alice/project --output json extra".as_slice(),
+ b"issue list alice/project/extra".as_slice(),
+ b"issue create alice/project --output json --output json".as_slice(),
+ &[b'x'; MAX_ISSUE_COMMAND_BYTES + 1],
+ ] {
+ assert!(parse_issue_command(command).is_err());
+ }
+
+ let mut input = Vec::new();
+ assert!(append_issue_input(&mut input, &[b'x'; MAX_ISSUE_INPUT_BYTES]).is_ok());
+ assert!(append_issue_input(&mut input, b"x").is_err());
+ assert!(matches!(
+ parse_issue_input(b"invalid\xff"),
+ Err(IssueCommandError::Input)
+ ));
+ }
}
tests/git_push_ssh.rs
Mode 100644 → 100644; object b59e5efb19e0 → 4faed4cd492e
@@ -13,6 +13,9 @@ )] #[path = "../src/git/mod.rs"] mod git; +#[allow(dead_code, reason = "the SSH push test does not use issue commands")] +#[path = "../src/issue.rs"] +mod issue; #[allow(dead_code, reason = "the SSH push test does not use repository policy")] #[path = "../src/policy.rs"] mod policy;
tests/git_ssh.rs
Mode 100644 → 100644; object da0c2555fe22 → df9617f13731
@@ -13,6 +13,9 @@ )] #[path = "../src/git/mod.rs"] mod git; +#[allow(dead_code, reason = "the SSH Git test does not use issue commands")] +#[path = "../src/issue.rs"] +mod issue; #[allow(dead_code, reason = "the SSH Git test does not use repository policy")] #[path = "../src/policy.rs"] mod policy;
tests/serve.rs
Mode 100644 → 100644; object f85e9265842e → e32b6d574430
@@ -613,6 +613,93 @@
);
assert!(machine.stderr.is_empty());
+ let created_issue = ssh_exec_with_input(
+ ssh,
+ &member_key,
+ &["issue", "create", "bob/example"],
+ b"First issue\nBody with **Markdown**.\n",
+ );
+ assert!(created_issue.status.success());
+ assert_eq!(
+ String::from_utf8(created_issue.stdout).expect("read issue create output"),
+ "Created issue bob/example#1.\n"
+ );
+ assert!(created_issue.stderr.is_empty());
+ let human_issues = ssh_exec(ssh, &member_key, &["issue", "list", "bob/example"]);
+ assert!(human_issues.status.success());
+ assert_eq!(
+ String::from_utf8(human_issues.stdout).expect("read human issue list"),
+ "#1 open First issue\n"
+ );
+ let machine_issues = ssh_exec(
+ ssh,
+ &member_key,
+ &["issue", "list", "bob/example", "--output", "json"],
+ );
+ assert!(machine_issues.status.success());
+ let machine_issues: serde_json::Value =
+ serde_json::from_slice(&machine_issues.stdout).expect("parse machine issue list");
+ assert_eq!(machine_issues["version"], 1);
+ assert_eq!(machine_issues["status"], "success");
+ assert_eq!(machine_issues["repository"]["owner"], "bob");
+ assert_eq!(machine_issues["repository"]["name"], "example");
+ assert_eq!(machine_issues["issues"][0]["number"], 1);
+ assert_eq!(machine_issues["issues"][0]["title"], "First issue");
+
+ let access_database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
+ .expect("open the issue access database");
+ access_database
+ .execute_batch("UPDATE repository SET visibility = 'private' WHERE slug = 'example';")
+ .expect("make the issue repository private");
+ let hidden = ssh_exec(
+ ssh,
+ &private_key,
+ &["issue", "list", "bob/example", "--output", "json"],
+ );
+ assert!(!hidden.status.success());
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&hidden.stdout)
+ .expect("parse hidden issue error")["error"]["code"],
+ "repository-unavailable"
+ );
+ access_database
+ .execute(
+ "INSERT INTO repository_collaborator
+ (repository_id, account_id, role, created_at)
+ SELECT repository.id, account.id, 'reader', 10
+ FROM repository, account
+ WHERE repository.slug = 'example' AND account.username = 'alice'",
+ [],
+ )
+ .expect("give the administrator reader access");
+ drop(access_database);
+ let reader_create = ssh_exec_with_input(
+ ssh,
+ &private_key,
+ &["issue", "create", "bob/example", "--output", "json"],
+ b"Reader issue\nCreated through the shared service.",
+ );
+ assert!(reader_create.status.success());
+ let reader_create: serde_json::Value =
+ serde_json::from_slice(&reader_create.stdout).expect("parse machine issue create");
+ assert_eq!(reader_create["version"], 1);
+ assert_eq!(reader_create["status"], "success");
+ assert_eq!(reader_create["issue"]["number"], 2);
+ assert_eq!(reader_create["issue"]["author"], "alice");
+
+ let invalid_issue = ssh_exec_with_input(
+ ssh,
+ &private_key,
+ &["issue", "create", "bob/example", "--output", "json"],
+ b"\nbody without a title",
+ );
+ assert!(!invalid_issue.status.success());
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&invalid_issue.stdout)
+ .expect("parse invalid issue input")["error"]["code"],
+ "invalid-input"
+ );
+
let duplicate = ssh_exec(
ssh,
&member_key,
@@ -660,6 +747,17 @@
"{\"version\":1,\"status\":\"error\",\"error\":{\"code\":\"account-unavailable\"}}\n"
);
assert!(suspended.stderr.is_empty());
+ let suspended_issues = ssh_exec(
+ ssh,
+ &member_key,
+ &["issue", "list", "bob/example", "--output", "json"],
+ );
+ assert!(!suspended_issues.status.success());
+ assert_eq!(
+ serde_json::from_slice::<serde_json::Value>(&suspended_issues.stdout)
+ .expect("parse suspended issue output")["error"]["code"],
+ "repository-unavailable"
+ );
server.terminate();
let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
@@ -686,6 +784,44 @@
),
]
);
+ let issues: Vec<(i64, String, String, String)> = database
+ .prepare(
+ "SELECT issue.number, issue.title, issue.body, account.username
+ FROM issue JOIN account ON account.id = issue.author_account_id
+ ORDER BY issue.number",
+ )
+ .expect("prepare the issue query")
+ .query_map([], |row| {
+ Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
+ })
+ .expect("query created issues")
+ .collect::<Result<_, _>>()
+ .expect("read created issues");
+ assert_eq!(
+ issues,
+ vec![
+ (
+ 1,
+ "First issue".to_owned(),
+ "Body with **Markdown**.\n".to_owned(),
+ "bob".to_owned(),
+ ),
+ (
+ 2,
+ "Reader issue".to_owned(),
+ "Created through the shared service.".to_owned(),
+ "alice".to_owned(),
+ ),
+ ]
+ );
+ let issue_events: i64 = database
+ .query_row(
+ "SELECT count(*) FROM repository_event WHERE kind = 'issue-created'",
+ [],
+ |row| row.get(0),
+ )
+ .expect("count issue create events");
+ assert_eq!(issue_events, 2);
let audit: Vec<(String, String)> = database
.prepare(
"SELECT target, outcome FROM audit_event
@@ -1236,6 +1372,48 @@
.args(command)
.output()
.expect("run an SSH repository command")
+}
+
+fn ssh_exec_with_input(
+ address: SocketAddr,
+ private_key: &Path,
+ command: &[&str],
+ input: &[u8],
+) -> Output {
+ let mut child = Command::new("ssh")
+ .args([
+ "-F",
+ "/dev/null",
+ "-o",
+ "BatchMode=yes",
+ "-o",
+ "IdentitiesOnly=yes",
+ "-o",
+ "StrictHostKeyChecking=no",
+ "-o",
+ "UserKnownHostsFile=/dev/null",
+ "-o",
+ "LogLevel=ERROR",
+ "-i",
+ ])
+ .arg(private_key)
+ .args(["-p", &address.port().to_string()])
+ .arg(format!("ignored@{}", address.ip()))
+ .args(command)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("start an SSH issue command");
+ child
+ .stdin
+ .take()
+ .expect("open SSH issue input")
+ .write_all(input)
+ .expect("write SSH issue input");
+ child
+ .wait_with_output()
+ .expect("finish an SSH issue command")
}
fn provision_account(
tests/ssh.rs
Mode 100644 → 100644; object 8ef49d75e78d → 8fc2de34b893
@@ -12,6 +12,12 @@
mod git;
#[allow(
dead_code,
+ reason = "the SSH identity test does not use issue commands"
+)]
+#[path = "../src/issue.rs"]
+mod issue;
+#[allow(
+ dead_code,
reason = "the SSH identity test does not use repository policy"
)]
#[path = "../src/policy.rs"]