Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,40 @@
# OpenLess Cloud Sync

## 范围
## Scope

本项目是独立的加密云同步服务。需求和对外合同以 [docs/index.md](docs/index.md) 指向的文件为准。服务端为 Rust/Axum + SQLite,应用接入归 `1-app/`;不能把本地测试通过当成已部署、全平台验收或独立安全审查完成。
This repository is an independent encrypted cloud-sync service. The requirements and public contract are the files linked from [docs/index.md](docs/index.md). The service uses Rust/Axum and SQLite; application integration belongs in `1-app/`. Passing local tests does not prove deployment, cross-platform acceptance, or an independent security review.

## 边界
## Boundaries

- 云端只接收配置快照的密文。加密密码、派生密钥、配置和服务 API 密钥的明文不得进入服务器接口、日志或运营工具。
- GitHub 仅用于确认账号身份;以 GitHub numeric ID 隔离数据,不能按用户名绑定旧数据。
- 所有密码学原语使用经过审查的实现,不自制算法、不降级明文、不以“永不被攻破”作为产品承诺。
- 这是独立于风格包市场的合同。旧应用 `/me/sync` 不得直接扩展为明文传输 API 密钥的接口。
- 未来服务代码、协议与测试须可开源审查;部署凭据、用户数据和密钥不属于源码。正式开源前必须明确许可证和第三方许可边界。
- The cloud service receives encrypted configuration snapshots only. Plaintext encryption passwords, derived keys, configuration, and service API keys must never enter server interfaces, logs, or operations tools.
- GitHub is used only to establish account identity. Data isolation uses the numeric GitHub ID; never bind existing data by username.
- Use reviewed cryptographic primitives. Do not invent algorithms, downgrade to plaintext, or promise that a system is impossible to break.
- This service has a contract independent of the style-pack marketplace. Do not extend the legacy `/me/sync` endpoint into an API that sends plaintext API keys.
- Service code, protocols, and tests must remain open to source review. Deployment credentials, user data, and keys do not belong in source. Before a public release, document the license and third-party license boundaries.

## 文档维护
## Documentation maintenance

需求行为以 `requirements.md` 为准;字段与 HTTP 行为以 `http-api.md` 和 `openapi.yaml` 为准;加密字节格式以 `encryption-protocol.md` 为准;应用改动入口集中于 `client-integration.md`。修改相关合同须同时核对错误码、示例、验收项和相对链接。部署信息归 `docs/deployment.md`,验证证据归 `docs/server-acceptance.md`。
Behavioral requirements are defined by `requirements.md`; fields and HTTP behavior by `http-api.md` and `openapi.yaml`; encrypted byte formats by `encryption-protocol.md`; application changes by `client-integration.md`. When changing a contract, also check error codes, examples, acceptance criteria, and relative links. Deployment information belongs in `docs/deployment.md`; verification evidence belongs in `docs/server-acceptance.md`.

## 开发与验证
## Development and verification

从本目录运行 `cargo fmt --check`、`cargo clippy --all-targets --locked -- -D warnings`、`cargo test --locked`。依赖使用 `Cargo.lock`;安全检查为 `cargo audit --deny warnings`。完整验证命令在 README。逻辑在 `src/`,迁移在 `migrations/`,测试在 `tests/`,运维模板在 `deploy/`。
Run these checks from this directory:

变更不得放宽体积、身份或 HTTPS 边界;不添加开发身份配置或可切换到任意上游的生产认证入口。数据库变更须有原子迁移、故障回滚和兼容验证。生产数据库和环境文件不得进入源码、打包产物或日志。
```text
cargo fmt --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --locked
cargo audit --deny warnings
```

Use `Cargo.lock`. The complete verification commands are in the README. Application logic is in `src/`, migrations are in `migrations/`, tests are in `tests/`, and operations templates are in `deploy/`.

Changes must not widen size, identity, or HTTPS boundaries. Do not add development identity configuration or a production authentication entry point that can switch to an arbitrary upstream. Database changes require an atomic migration, failure rollback, and compatibility verification. Production databases and environment files must not enter source, packaged artifacts, or logs.

## Contribution workflow

- Follow the required `beta -> task` flow: when a `beta` integration branch exists, start from its latest revision and create a dedicated `task/<name>` branch. Never push task commits directly to `beta` or `main`. This server repository currently has no `beta` branch, so tasks explicitly based on the server's latest `main` use a dedicated task branch from `main`.
- Submit changes through a pull request targeting `main`. Do not push directly to `main`, and do not treat a local commit as submission.
- Wait for and review every required CI check before reporting the pull request as complete or mergeable. A local pass does not replace the required CI review.
- Commit only source code, tests, build or deployment configuration, and unfinished planning documents. Remove completed reports, local build records, generated artifacts, and obsolete planning files.
- All source-code comments must be written in English.
24 changes: 12 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/client-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@

旧 `/me/sync` 继续只描述旧手动快照。新用户未同意前不自动迁移。若导入旧备份,先按旧合同校验,在本地与当前数据合并,用户确认后再加密创建新库;旧服务器数据是否删除要单独确认。

### Encrypted server integration boundary

The encrypted service receives only the `SnapshotUpload` envelope and treats its ciphertext as opaque. Client commit `f71ffd54` adds portable preferences such as `cursorContextEnabled`, `polishContextWindowMinutes`, `polishSkipMaxChars`, `streamingInsert`, `windowsCompositionInput`, `selectionVoice*`, and layout/history/update settings inside the encrypted `preferences` document. The envelope, protocol metadata, OpenAPI schema, and size limits are unchanged; the server must store and return the complete body without decrypting it or adding plaintext preference handling.

## 2. 新的本地 IPC 合同

GitHub 登录统一复用现有 OpenLess OAuth App。已登录时直接在原生层读取已有令牌并交换短期同步会话;下表中的登录命令如保留,只能委托现有登录流程,不创建第二套应用或要求重复授权。退出 OpenLess 账号时同时停止同步、撤销同步会话并清理本机解锁材料。
Expand Down
2 changes: 2 additions & 0 deletions docs/encryption-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ XChaCha20-Poly1305 使用扩展 nonce 支持各设备独立生成随机 nonce,

## 3. 解密后的逻辑快照

The logical snapshot is client-owned plaintext and remains inside the AEAD ciphertext. The client change in commit `f71ffd54` adds portable preference values such as `cursorContextEnabled`, `polishContextWindowMinutes`, `polishSkipMaxChars`, `streamingInsert`, `windowsCompositionInput`, `selectionVoice*`, and layout/history/update settings to the encrypted `preferences` document. These fields do not change `SnapshotUpload`, `payloadSchemaVersion`, the codec, or any protocol size limit. The server must validate only the public envelope, store the complete request bytes, and return those bytes unchanged; it must not decrypt the snapshot or add plaintext preference DTOs, migrations, or field-level handling.

顶层 UTF-8 JSON 对象:

| 字段 | 类型 | 约束 |
Expand Down
2 changes: 2 additions & 0 deletions docs/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@
4. 新操作核对 ETag、baseRevision、vault/key/salt 和 kind,原子提交密文、递增版本及回执。操作的“已提交”和快照变化不能分开出现。
5. 响应不回传整个密文,只回传该操作的确认结果。

The encrypted snapshot body is opaque to this service. Portable preference fields added by client commit `f71ffd54` stay inside the authenticated ciphertext, so they require no server DTO, database column, OpenAPI field, protocol metadata, or size-limit change. The server must persist and return the complete envelope without decrypting, filtering, or reconstructing its plaintext.

| kind | 允许的当前状态 | 约束 |
| --- | --- | --- |
| `create` | empty / deleted | 新 vaultId、keyId、盐;deleted 状态仍使用其现有 baseRevision,不能归零 |
Expand Down
53 changes: 51 additions & 2 deletions tests/store_contract.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use openless_cloud_sync::{
protocol::{Snapshot, hash},
protocol::{Metadata, Snapshot, hash},
store::{Mutation, Store},
};
use serde_json::json;
use serde_json::{Value, json};

fn sample(owner: &str) -> Vec<u8> {
let ciphertext = vec![42; 65552];
Expand All @@ -18,6 +18,55 @@ fn sample(owner: &str) -> Vec<u8> {
})).unwrap()
}

#[tokio::test]
async fn current_client_envelope_round_trips_as_opaque_ciphertext() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("test.db");
let store = Store::open(&path).unwrap();
let fixture: Value = serde_json::from_str(include_str!("vectors/v1.json")).unwrap();
let value = fixture["vectors"][0]["snapshot"].clone();
let body = serde_json::to_vec(&value).unwrap();
let snapshot: Snapshot = serde_json::from_value(value.clone()).unwrap();
let owner = snapshot.owner_github_id.clone();
let ciphertext = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&snapshot.ciphertext)
.unwrap();
let prior = Metadata {
protocol_version: 1,
state: "active".into(),
owner_github_id: owner.clone(),
revision: snapshot.base_revision.clone(),
vault_id: Some(snapshot.vault_id.clone()),
key_id: Some(snapshot.key_id.clone()),
updated_at: Some("2026-09-23T00:00:00Z".into()),
payload_schema_version: Some(snapshot.payload_schema_version),
ciphertext_bytes: ciphertext.len(),
ciphertext_sha256: Some(snapshot.ciphertext_sha256.clone()),
last_operation_id: Some(uuid::Uuid::new_v4().to_string()),
};
let db = rusqlite::Connection::open(&path).unwrap();
db.execute(
"INSERT INTO vaults(owner,metadata,snapshot) VALUES(?1,?2,?3)",
rusqlite::params![owner, serde_json::to_string(&prior).unwrap(), body.clone()],
)
.unwrap();

let request = Mutation::new(
snapshot.owner_github_id.clone(),
"PUT",
"/v1/me/vault/snapshot",
Some(prior.etag()),
snapshot.operation_id.clone(),
body.clone(),
);
let (_, replayed) = store.mutate(request).await.unwrap();
assert!(!replayed);
let metadata = store.metadata(snapshot.owner_github_id).await.unwrap();
let downloaded = store.download(owner, metadata.etag()).await.unwrap();
assert_eq!(downloaded, body);
assert_eq!(serde_json::from_slice::<Value>(&downloaded).unwrap(), value);
}

#[tokio::test]
async fn replay_precedes_cas_and_is_scoped_to_account() {
let temp = tempfile::tempdir().unwrap();
Expand Down
Loading