{"record":{"id":"bc506cbc9b223192","repo":"vercel/turborepo","slug":"refusing-to-write-structured-log-to-symlink","errorCode":null,"errorMessage":"refusing to write structured log to symlink: {}","messagePattern":"refusing to write structured log to symlink: (.+?)","errorType":"exception","errorClass":"std::io::Error","httpStatus":null,"severity":"error","filePath":"crates/turborepo-log/src/sinks/structured.rs","lineNumber":171,"sourceCode":"            if self.has_entries {\n                buf.extend_from_slice(b\",\\n\");\n            }\n            buf.extend_from_slice(json.as_bytes());\n            self.has_entries = true;\n        }\n        buf.extend_from_slice(b\"\\n]\\n\");\n\n        // The file always ends with `]\\n` (2 bytes). Seek there and\n        // overwrite with the new entries + fresh closing bracket.\n        self.file.seek(SeekFrom::End(-2))?;\n        self.file.write_all(&buf)?;\n\n        Ok(())\n    }\n}\n\nfn symlink_error(path: &Path) -> std::io::Error {\n    std::io::Error::new(\n        std::io::ErrorKind::AlreadyExists,\n        format!(\n            \"refusing to write structured log to symlink: {}\",\n            path.display()\n        ),\n    )\n}\n\nfn map_open_error(path: &Path, error: std::io::Error) -> std::io::Error {\n    #[cfg(unix)]\n    {\n        if error.raw_os_error() == Some(libc::ELOOP) {\n            return symlink_error(path);\n        }\n    }\n\n    error\n}","sourceCodeStart":153,"sourceCodeEnd":189,"githubUrl":"https://github.com/vercel/turborepo/blob/f9245100cf0d31d96628804ead485f6bf226e55a/crates/turborepo-log/src/sinks/structured.rs#L153-L189","documentation":"The structured-log sink (JsonArrayFile::create, structured.rs:100-129) opens its JSON log file with O_NOFOLLOW on Unix and FILE_FLAG_OPEN_REPARSE_POINT on Windows, then rejects the open when metadata says the target is a symlink (map_open_error also maps ELOOP to this). Writing through a symlink would let an attacker redirect or swap the log, so the sink refuses with AlreadyExists 'refusing to write structured log to symlink' instead of following it.","triggerScenarios":"Structured logging enabled and the configured log path (or its final component) is a symlink — users symlinking the log dir into tmpfs/shared storage, dotfile managers, or CI images that symlink log locations. On Unix, symlink loops surface as ELOOP mapped to the same message.","commonSituations":"`ln -s /dev/shm/turbo-logs ~/.turbo/logs` style setups for speed Shared/network log directories via symlink in containers Symlink chains that loop (ELOOP)","solutions":["Point the structured log output at a real directory (remove the symlink, `mkdir -p` a physical dir)","Mount tmpfs directly at the log dir instead of symlinking to it","Fix or remove looping symlinks on the log path (the ELOOP case)"],"exampleFix":"# before\nln -s /dev/shm/logs ~/.turbo/logs   # -> refusal at startup\n# after\nsudo mount -t tmpfs -o size=64m,mode=700 tmpfs ~/.turbo/logs","handlingStrategy":"validation","validationCode":"// before configuring structured logging\nlet m = std::fs::symlink_metadata(log_path)?;\nif m.file_type().is_symlink() {\n    anyhow::bail!(\"{log_path} is a symlink; refusing to configure structured log\");\n}","typeGuard":"fn is_safe_log_target(p: &Path) -> bool {\n    std::fs::symlink_metadata(p).map(|m| !m.file_type().is_symlink()).unwrap_or(true)\n}","tryCatchPattern":"// AlreadyExists with this message is fatal by design — fix the path, do not retry\nif let Err(e) = sink_create(path) {\n    if e.kind() == std::io::ErrorKind::AlreadyExists && e.to_string().contains(\"symlink\") {\n        path = next_physical_log_dir();\n    }\n}","preventionTips":["Mount tmpfs at the log dir instead of symlinking to it","Audit dotfile-manager-managed dirs used for logs","Avoid symlink chains anywhere on the log path (ELOOP maps here too)"],"tags":["logging","symlink","security","turborepo","nofollow"],"backgroundTag":"symlink-security-refusal","analyzedSha":"f9245100cf0d31d96628804ead485f6bf226e55a","analyzedAt":"2026-08-17T10:46:15.696Z","schemaVersion":2},"datasetVersion":"2026-08-21T10:36:37.832Z"}